// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Nima Shafie <nimzshafie@gmail.com>
static IMG_LOGO_TEXT: &[u8] = include_bytes!("../assets/logo/logo-text.png");
static IMG_LOGO_SMALL: &[u8] = include_bytes!("../assets/logo/small-logo.png");
static IMG_ICON_C: &[u8] = include_bytes!("../assets/icons/c.png");
static IMG_ICON_CPP: &[u8] = include_bytes!("../assets/icons/cpp.png");
static IMG_ICON_CSHARP: &[u8] = include_bytes!("../assets/icons/c-sharp.png");
static IMG_ICON_PYTHON: &[u8] = include_bytes!("../assets/icons/python.png");
static IMG_ICON_SHELL: &[u8] = include_bytes!("../assets/icons/shell.png");
static IMG_ICON_POWERSHELL: &[u8] = include_bytes!("../assets/icons/powershell.png");
static IMG_ICON_JAVASCRIPT: &[u8] = include_bytes!("../assets/icons/java-script.png");
static IMG_ICON_HTML: &[u8] = include_bytes!("../assets/icons/html-5.png");
static IMG_ICON_JAVA: &[u8] = include_bytes!("../assets/icons/java.png");
static IMG_ICON_VB: &[u8] = include_bytes!("../assets/icons/visual-basic.png");
static IMG_ICON_ASSEMBLY: &[u8] = include_bytes!("../assets/icons/asm.png");
static IMG_ICON_GO: &[u8] = include_bytes!("../assets/icons/go.png");
static IMG_ICON_R: &[u8] = include_bytes!("../assets/icons/r.png");
static IMG_ICON_XML: &[u8] = include_bytes!("../assets/icons/xml.png");
static IMG_ICON_GROOVY: &[u8] = include_bytes!("../assets/icons/groovy.png");
static IMG_ICON_DOCKERFILE: &[u8] = include_bytes!("../assets/icons/docker.png");
static IMG_ICON_MAKEFILE: &[u8] = include_bytes!("../assets/icons/makefile.svg");
static IMG_ICON_PERL: &[u8] = include_bytes!("../assets/icons/perl.svg");
pub(crate) mod auth;
pub(crate) mod confluence;
pub(crate) mod error;
pub(crate) mod git_browser;
pub(crate) mod git_webhook;
pub(crate) mod integrations;
use std::{
collections::{HashMap, VecDeque},
fmt::Write,
fs,
net::{IpAddr, SocketAddr},
path::{Path, PathBuf},
process::Stdio,
sync::{Arc, OnceLock},
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use anyhow::{Context, Result};
use askama::Template;
use axum::{
body::Body,
extract::{DefaultBodyLimit, Form, Path as AxumPath, Query, State},
http::{header, HeaderValue, Request, StatusCode},
middleware::{self, Next},
response::{Html, IntoResponse, Response},
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
use sloc_config::{
AppConfig, BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy,
MixedLinePolicy,
};
use sloc_git::ScheduleStore;
#[derive(Clone)]
pub(crate) struct CspNonce(pub(crate) String);
static CHART_JS: &[u8] = include_bytes!("../static/chart.umd.min.js");
static REPORT_CHART_JS: &[u8] = include_bytes!("../static/chart.min.js");
use sloc_core::{
analyze, compute_delta, compute_multi_delta, read_json, AnalysisRun, CleanupPolicy,
CleanupPolicyStore, FileChangeStatus, MultiScanComparison, RegistryEntry, ScanRegistry,
ScanSummarySnapshot, SummaryTotals, WatchedDirsStore,
};
use sloc_report::{
render_html, render_html_with_delta, render_sub_report_html, write_pdf_from_html,
write_pdf_from_run, ReportDeltaContext,
};
const MAX_CONCURRENT_ANALYSES: usize = 4;
/// Windows-only helpers that force the native file-picker dialog into the
/// foreground instead of appearing minimised behind other windows.
///
/// Strategy: (a) attach the `spawn_blocking` thread's input queue to the current
/// foreground thread so that windows created on our thread inherit focus; and
/// (b) spin a polling watcher that finds the dialog by title and calls
/// `SetForegroundWindow` + `FlashWindowEx` once it appears.
#[cfg(target_os = "windows")]
#[allow(clippy::upper_case_acronyms)]
#[allow(dead_code)]
mod win_dialog_focus {
#[cfg(feature = "native-dialog")]
use std::mem::size_of;
type HWND = *mut core::ffi::c_void;
type DWORD = u32;
type UINT = u32;
type BOOL = i32;
// Mirror of FLASHWINFO — only needed with the native-dialog rfd integration.
#[cfg(feature = "native-dialog")]
#[repr(C)]
#[allow(non_snake_case)]
struct FLASHWINFO {
cbSize: UINT,
hwnd: HWND,
dwFlags: DWORD,
uCount: UINT,
dwTimeout: DWORD,
}
#[cfg(feature = "native-dialog")]
const FLASHW_ALL: DWORD = 0x3;
#[cfg(feature = "native-dialog")]
const FLASHW_TIMERNOFG: DWORD = 0xC;
#[link(name = "user32")]
extern "system" {
fn GetForegroundWindow() -> HWND;
fn SetForegroundWindow(hWnd: HWND) -> BOOL;
fn ShowWindow(hWnd: HWND, nCmdShow: i32) -> BOOL;
fn BringWindowToTop(hWnd: HWND) -> BOOL;
fn SetWindowPos(
hWnd: HWND,
hWndAfter: HWND,
x: i32,
y: i32,
cx: i32,
cy: i32,
flags: UINT,
) -> BOOL;
fn GetWindowThreadProcessId(hWnd: HWND, lpdwProcessId: *mut DWORD) -> DWORD;
fn AttachThreadInput(idAttach: DWORD, idAttachTo: DWORD, fAttach: BOOL) -> BOOL;
#[cfg(feature = "native-dialog")]
fn FlashWindowEx(pfwi: *const FLASHWINFO) -> BOOL;
fn FindWindowW(lpClassName: *const u16, lpWindowName: *const u16) -> HWND;
fn FindWindowExW(
hWndParent: HWND,
hWndChildAfter: HWND,
lpszClass: *const u16,
lpszWindow: *const u16,
) -> HWND;
// Undocumented but present on all Windows versions since XP; bypasses
// the foreground-lock that blocks SetForegroundWindow from non-foreground
// processes. fAltTab=1 simulates the Alt+Tab activation path.
fn SwitchToThisWindow(hWnd: HWND, fAltTab: BOOL);
}
#[link(name = "kernel32")]
extern "system" {
#[cfg(feature = "native-dialog")]
fn GetCurrentThreadId() -> DWORD;
}
#[link(name = "shell32")]
extern "system" {
// Opens a folder (or file) via the Windows shell. Passing the current
// foreground window as `hwnd` gives the new window proper activation
// context so it surfaces in the foreground without needing
// AttachThreadInput or SetForegroundWindow hacks.
fn ShellExecuteW(
hwnd: HWND,
lpOperation: *const u16,
lpFile: *const u16,
lpParameters: *const u16,
lpDirectory: *const u16,
nShowCmd: i32,
) -> isize; // HINSTANCE (>32 = success)
}
/// Attaches our thread's input to the foreground window's thread so that
/// windows created on our thread inherit foreground focus. Returns the
/// foreground thread ID (needed for `detach_from_foreground`), or 0 if
/// the thread was already the foreground thread.
#[cfg(feature = "native-dialog")]
pub fn attach_to_foreground() -> DWORD {
unsafe {
let fg_hwnd = GetForegroundWindow();
if fg_hwnd.is_null() {
return 0;
}
let fg_tid = GetWindowThreadProcessId(fg_hwnd, core::ptr::null_mut());
let my_tid = GetCurrentThreadId();
if fg_tid == my_tid {
return 0;
}
AttachThreadInput(my_tid, fg_tid, 1);
fg_tid
}
}
/// Undoes `attach_to_foreground`.
#[cfg(feature = "native-dialog")]
pub fn detach_from_foreground(fg_tid: DWORD) {
if fg_tid == 0 {
return;
}
unsafe {
AttachThreadInput(GetCurrentThreadId(), fg_tid, 0);
}
}
unsafe fn snapshot_explorer_hwnds(class_w: &[u16]) -> std::collections::HashSet<usize> {
let mut existing = std::collections::HashSet::new();
let mut prev: HWND = core::ptr::null_mut();
loop {
let w = FindWindowExW(
core::ptr::null_mut(),
prev,
class_w.as_ptr(),
core::ptr::null(),
);
if w.is_null() {
break;
}
existing.insert(w as usize);
prev = w;
}
existing
}
unsafe fn find_new_explorer_hwnd(
class_w: &[u16],
existing: &std::collections::HashSet<usize>,
) -> Option<HWND> {
let mut prev: HWND = core::ptr::null_mut();
loop {
let w = FindWindowExW(
core::ptr::null_mut(),
prev,
class_w.as_ptr(),
core::ptr::null(),
);
if w.is_null() {
return None;
}
if !existing.contains(&(w as usize)) {
return Some(w);
}
prev = w;
}
}
unsafe fn bring_to_front(hwnd: HWND) {
// SW_RESTORE = 9 — same sequence as flash_dialog_when_ready.
// SwitchToThisWindow bypasses foreground-lock so the window surfaces
// regardless of which process currently has focus.
ShowWindow(hwnd, 9);
SwitchToThisWindow(hwnd, 1);
SetForegroundWindow(hwnd);
BringWindowToTop(hwnd);
}
/// Opens `path` in Windows Explorer and forces it to the foreground.
/// `ShellExecuteW` alone cannot guarantee foreground placement when the
/// caller is not the foreground process (the browser is). After launching,
/// we poll for a new `CabinetWClass` window and call `SwitchToThisWindow` —
/// an undocumented API that bypasses Windows' foreground-lock restriction
/// so the window surfaces regardless of which process currently has focus.
pub fn open_folder_foreground(path: std::path::PathBuf) {
std::thread::spawn(move || {
use std::os::windows::ffi::OsStrExt;
let op: Vec<u16> = "explore\0".encode_utf16().collect();
let mut path_w: Vec<u16> = path.as_os_str().encode_wide().collect();
path_w.push(0);
let class_w: Vec<u16> = "CabinetWClass\0".encode_utf16().collect();
unsafe {
// Snapshot every existing Explorer window before we launch so
// we can identify the newly created one.
let existing = snapshot_explorer_hwnds(&class_w);
let fg_hwnd = GetForegroundWindow();
// SW_SHOWNORMAL = 1
ShellExecuteW(
fg_hwnd,
op.as_ptr(),
path_w.as_ptr(),
core::ptr::null(),
core::ptr::null(),
1,
);
// Poll up to ~3 s for a new CabinetWClass window to appear,
// then use SwitchToThisWindow (bypasses foreground-lock) to
// bring it in front of the browser and everything else.
for _ in 0..40 {
std::thread::sleep(std::time::Duration::from_millis(75));
if let Some(w) = find_new_explorer_hwnd(&class_w, &existing) {
bring_to_front(w);
return;
}
}
// Fallback: Explorer reused an existing window — bring whichever
// CabinetWClass window is first in Z-order to the front.
let w = FindWindowW(class_w.as_ptr(), core::ptr::null());
if !w.is_null() {
bring_to_front(w);
}
}
});
}
/// Spawns a short-lived watcher thread that polls for a dialog window
/// matching `title` and, once found, forces it to the foreground and
/// flashes its taskbar button until the user interacts with it.
#[cfg(feature = "native-dialog")]
pub fn flash_dialog_when_ready(title: String) {
std::thread::spawn(move || {
let title_w: Vec<u16> = title.encode_utf16().chain(core::iter::once(0)).collect();
for _ in 0..40 {
std::thread::sleep(std::time::Duration::from_millis(80));
unsafe {
let hwnd = FindWindowW(core::ptr::null(), title_w.as_ptr());
if !hwnd.is_null() {
SetForegroundWindow(hwnd);
BringWindowToTop(hwnd);
#[allow(non_snake_case)]
FlashWindowEx(&FLASHWINFO {
// size_of returns usize; Win32 struct field is u32 (UINT).
// struct size fits trivially within u32.
#[allow(clippy::cast_possible_truncation)]
cbSize: size_of::<FLASHWINFO>() as UINT,
hwnd,
dwFlags: FLASHW_ALL | FLASHW_TIMERNOFG,
uCount: 3,
dwTimeout: 0,
});
break;
}
}
}
});
}
}
/// Sliding-window rate limiter keyed by client IP.
/// Uses only std primitives — no external crate required.
pub(crate) struct IpRateLimiter {
window: Duration,
max_requests: usize,
pub(crate) auth_lockout_threshold: u32,
auth_lockout_window: Duration,
state: std::sync::Mutex<HashMap<IpAddr, VecDeque<Instant>>>,
auth_failures: std::sync::Mutex<HashMap<IpAddr, (u32, Instant)>>,
}
impl IpRateLimiter {
pub(crate) fn new(
window: Duration,
max_requests: usize,
auth_lockout_threshold: u32,
auth_lockout_window: Duration,
) -> Self {
Self {
window,
max_requests,
auth_lockout_threshold,
auth_lockout_window,
state: std::sync::Mutex::new(HashMap::new()),
auth_failures: std::sync::Mutex::new(HashMap::new()),
}
}
// The MutexGuard `state` must live as long as `bucket` borrows from it,
// so it cannot be dropped any earlier than the end of the inner block.
#[allow(clippy::significant_drop_tightening)]
pub(crate) fn is_allowed(&self, ip: IpAddr) -> bool {
let now = Instant::now();
let cutoff = now.checked_sub(self.window).unwrap_or(now);
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if state.len() > 10_000 {
state.retain(|_, bucket| {
while bucket.front().is_some_and(|t| *t <= cutoff) {
bucket.pop_front();
}
!bucket.is_empty()
});
}
let bucket = state.entry(ip).or_default();
while bucket.front().is_some_and(|t| *t <= cutoff) {
bucket.pop_front();
}
if bucket.len() >= self.max_requests {
false
} else {
bucket.push_back(now);
true
}
}
pub(crate) fn record_auth_failure(&self, ip: IpAddr) {
let now = Instant::now();
let mut map = self
.auth_failures
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
map.entry(ip)
.and_modify(|e| {
e.0 += 1;
e.1 = now;
})
.or_insert_with(|| (1, now));
}
pub(crate) fn is_auth_locked_out(&self, ip: IpAddr) -> bool {
let mut map = self
.auth_failures
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let expired = map
.get(&ip)
.is_some_and(|e| e.1.elapsed() > self.auth_lockout_window);
if expired {
map.remove(&ip);
return false;
}
map.get(&ip)
.is_some_and(|e| e.0 >= self.auth_lockout_threshold)
}
pub(crate) fn auth_lockout_remaining_secs(&self, ip: IpAddr) -> u64 {
let map = self
.auth_failures
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
map.get(&ip).map_or(0, |e| {
self.auth_lockout_window
.checked_sub(e.1.elapsed())
.map_or(0, |r| r.as_secs())
})
}
pub(crate) fn spawn_pruning_task(limiter: Arc<Self>) {
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_mins(1));
interval.tick().await; // consume the immediate first tick
loop {
interval.tick().await;
let now = Instant::now();
let cutoff = now.checked_sub(limiter.window).unwrap_or(now);
{
let mut state = limiter
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state.retain(|_, bucket| {
while bucket.front().is_some_and(|t| *t <= cutoff) {
bucket.pop_front();
}
!bucket.is_empty()
});
}
{
let mut auth = limiter
.auth_failures
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
auth.retain(|_, e| e.1.elapsed() <= limiter.auth_lockout_window);
}
}
});
}
}
/// Periodically removes upload staging directories older than `SLOC_UPLOAD_TTL_HOURS` hours
/// (default 4). This prevents orphaned uploads from filling the disk when a client uploads
/// files but never triggers a scan.
fn spawn_upload_staging_cleanup() {
tokio::spawn(async move {
let ttl_hours: u64 = std::env::var("SLOC_UPLOAD_TTL_HOURS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(4);
let ttl_secs = ttl_hours * 3600;
let mut interval = tokio::time::interval(Duration::from_hours(1));
interval.tick().await; // consume the immediate first tick
loop {
interval.tick().await;
let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
let Ok(mut dir) = tokio::fs::read_dir(&upload_root).await else {
continue;
};
while let Ok(Some(entry)) = dir.next_entry().await {
let path = entry.path();
let age_secs = tokio::fs::metadata(&path)
.await
.ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.elapsed().ok())
.map_or(0, |d| d.as_secs());
if age_secs > ttl_secs {
tracing::debug!(
event = "upload_staging_cleanup",
path = %path.display(),
age_secs,
"removing stale upload staging directory"
);
let _ = tokio::fs::remove_dir_all(&path).await;
}
}
}
});
}
/// Carries context from scan time to result render time (stored inside `RunArtifacts`).
#[derive(Clone, Debug, Default)]
struct RunResultContext {
prev_entry: Option<RegistryEntry>,
prev_scan_count: usize,
project_path: String,
/// COCOMO mode chosen by the user in the scan wizard (`organic` | `semi_detached` | `embedded`).
cocomo_mode: String,
/// Per-file complexity alert threshold: files above this are highlighted. 0 = off.
complexity_alert: u32,
/// Whether duplicate files should be excluded from displayed SLOC totals.
#[allow(dead_code)]
exclude_duplicates: bool,
}
/// State of a background async scan, keyed by `wait_id` in `AppState::async_runs`.
#[derive(Clone)]
enum AsyncRunState {
Running {
started_at: std::time::Instant,
cancel_token: Arc<std::sync::atomic::AtomicBool>,
phase: Arc<std::sync::Mutex<String>>,
files_done: Arc<std::sync::atomic::AtomicUsize>,
files_total: Arc<std::sync::atomic::AtomicUsize>,
},
/// `run_id` so the status endpoint can redirect to /`runs/result/{run_id`}.
Complete {
run_id: String,
},
Failed {
message: String,
},
Cancelled,
}
/// A saved scan configuration profile — stores the form parameters so users can
/// re-run a favourite scan with one click.
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ScanProfile {
id: String,
name: String,
created_at: String,
/// The raw scan-form parameters serialized as JSON.
params: serde_json::Value,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct ScanProfileStore {
profiles: Vec<ScanProfile>,
}
impl ScanProfileStore {
fn load(path: &std::path::Path) -> Self {
fs::read_to_string(path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
fn save(&self, path: &std::path::Path) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(self)?;
fs::write(path, json)?;
Ok(())
}
}
#[derive(Clone)]
pub(crate) struct AppState {
pub(crate) base_config: AppConfig,
pub(crate) artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
pub(crate) async_runs: Arc<Mutex<HashMap<String, AsyncRunState>>>,
pub(crate) registry: Arc<Mutex<ScanRegistry>>,
pub(crate) registry_path: PathBuf,
pub(crate) analyze_semaphore: Arc<tokio::sync::Semaphore>,
pub(crate) server_mode: bool,
pub(crate) tls_enabled: bool,
pub(crate) api_keys: Arc<Vec<secrecy::SecretBox<String>>>,
pub(crate) rate_limiter: Arc<IpRateLimiter>,
pub(crate) trust_proxy: bool,
/// Allowlist of proxy IPs that are permitted to set X-Forwarded-For. Only honoured when
/// `trust_proxy` is true. Empty list means X-Forwarded-For is never trusted.
pub(crate) trusted_proxy_ips: Vec<IpAddr>,
/// Directory where remote repositories are cloned for git-browser scans.
pub(crate) git_clones_dir: PathBuf,
/// Persisted list of webhook / poll schedules.
pub(crate) schedules: Arc<Mutex<ScheduleStore>>,
pub(crate) schedules_path: PathBuf,
/// Named scan profiles saved by the user via the web UI.
pub(crate) scan_profiles: Arc<Mutex<ScanProfileStore>>,
pub(crate) scan_profiles_path: PathBuf,
pub(crate) sessions: Arc<std::sync::Mutex<HashMap<String, Instant>>>,
/// Persisted Confluence integration settings.
pub(crate) confluence: Arc<Mutex<confluence::ConfluenceConfigStore>>,
pub(crate) confluence_path: PathBuf,
/// Directories the user has pinned for auto-scanning of external reports.
pub(crate) watched_dirs: Arc<Mutex<WatchedDirsStore>>,
pub(crate) watched_dirs_path: PathBuf,
/// Persisted auto-cleanup policy (age/count limits + interval).
pub(crate) cleanup_policy: Arc<Mutex<CleanupPolicyStore>>,
pub(crate) cleanup_policy_path: PathBuf,
/// Handle for the running cleanup background task; replaced on policy change.
pub(crate) cleanup_task_handle: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
}
type PendingPdf = Option<(PathBuf, PathBuf, bool)>;
/// Parameters for the fire-and-forget HTML + PDF background task.
#[derive(Clone, Debug)]
pub(crate) struct RunArtifacts {
output_dir: PathBuf,
html_path: Option<PathBuf>,
pdf_path: Option<PathBuf>,
json_path: Option<PathBuf>,
csv_path: Option<PathBuf>,
xlsx_path: Option<PathBuf>,
scan_config_path: Option<PathBuf>,
report_title: String,
result_context: RunResultContext,
}
#[allow(clippy::too_many_lines)] // route registration table; splitting would obscure router structure
fn build_router(state: AppState) -> Router {
let protected = Router::new()
.route("/", get(splash))
.route("/scan-setup", get(scan_setup_handler))
.route("/scan", get(index))
.route("/analyze", post(analyze_handler))
.route("/preview", get(preview_handler))
.route("/api/suggest-coverage", get(api_suggest_coverage))
.route("/pick-directory", get(pick_directory_handler))
.route("/open-path", get(open_path_handler))
.route("/pick-file", get(pick_file_handler))
.route(
"/api/upload-directory",
post(upload_directory_handler).layer(DefaultBodyLimit::max(64 * 1024 * 1024)),
)
.route(
"/api/upload-file",
post(upload_file_handler).layer(DefaultBodyLimit::max(30 * 1024 * 1024)),
)
.route(
"/api/upload-tarball",
post(upload_tarball_handler).layer(DefaultBodyLimit::disable()),
)
.route("/locate-report", post(locate_report_handler))
.route("/locate-reports-dir", post(locate_reports_dir_handler))
.route("/relocate-scan", post(relocate_scan_handler))
.route("/watched-dirs/add", post(add_watched_dir_handler))
.route("/watched-dirs/remove", post(remove_watched_dir_handler))
.route("/watched-dirs/refresh", post(refresh_watched_dirs_handler))
.route("/view-reports", get(history_handler))
.route("/compare-scans", get(compare_select_handler))
.route("/compare", get(compare_handler))
.route("/multi-compare", get(multi_compare_handler))
.route("/images/{folder}/{file}", get(image_handler))
.route("/runs/{artifact}/{run_id}", get(artifact_handler))
.route("/api/metrics/latest", get(api_metrics_latest_handler))
.route("/api/metrics/{run_id}", get(api_metrics_run_handler))
.route("/api/metrics/history", get(api_metrics_history_handler))
.route(
"/api/metrics/submodules",
get(api_metrics_submodules_handler),
)
.route("/api/ingest", post(api_ingest_handler))
.route("/api/project-history", get(project_history_handler))
.route("/trend-reports", get(trend_report_handler))
.route("/test-metrics", get(test_metrics_handler))
.route("/api/runs/{wait_id}/status", get(async_run_status_handler))
.route("/api/runs/{wait_id}/cancel", post(cancel_run_handler))
.route("/api/runs/{run_id}/pdf-status", get(pdf_status_handler))
.route("/runs/result/{run_id}", get(async_run_result_handler))
.route("/embed/summary", get(embed_handler))
// ── Git browser ────────────────────────────────────────────────────────
.route("/git-browser", get(git_browser::git_browser_handler))
.route("/api/git/refs", get(git_browser::api_list_refs))
.route("/api/git/scan-ref", get(git_browser::api_scan_ref))
.route("/api/git/compare-refs", get(git_browser::api_compare_refs))
// ── Report export (HTML→PDF via headless Chrome) ──────────────────────
.route("/export/pdf", post(export_pdf_handler))
// ── Config export / import ─────────────────────────────────────────────
.route("/export-config", get(export_config_handler))
.route("/import-config", post(import_config_handler))
// ── Scan profiles ──────────────────────────────────────────────────────
.route("/api/scan-profiles", get(api_list_scan_profiles))
.route("/api/scan-profiles", post(api_save_scan_profile))
.route(
"/api/scan-profiles/{id}",
axum::routing::delete(api_delete_scan_profile),
)
// ── Integrations (webhooks + Confluence) ──────────────────────────────
.route("/integrations", get(integrations::integrations_handler))
.route(
"/webhook-setup",
get(|| async { axum::response::Redirect::permanent("/integrations") }),
)
.route(
"/confluence-setup",
get(|| async { axum::response::Redirect::permanent("/integrations#confluence") }),
)
.route("/api/schedules", get(git_webhook::api_list_schedules))
.route("/api/schedules", post(git_webhook::api_create_schedule))
.route(
"/api/schedules",
axum::routing::delete(git_webhook::api_delete_schedule),
)
.route(
"/api/confluence/config",
get(confluence::api_get_confluence_config),
)
.route(
"/api/confluence/config",
post(confluence::api_save_confluence_config),
)
.route(
"/api/confluence/test",
post(confluence::api_test_confluence),
)
.route(
"/api/confluence/post",
post(confluence::api_post_to_confluence),
)
.route(
"/api/confluence/wiki-markup",
get(confluence::api_wiki_markup),
)
// ── Run lifecycle: bundle download + delete + cleanup ─────────────────
.route("/api/runs/{run_id}/bundle", get(download_bundle_handler))
.route(
"/api/runs/{run_id}",
axum::routing::delete(delete_run_handler),
)
.route("/api/runs/cleanup", post(cleanup_runs_handler))
// ── Auto-cleanup policy ────────────────────────────────────────────────
.route(
"/api/cleanup-policy",
get(api_get_cleanup_policy)
.post(api_save_cleanup_policy)
.delete(api_delete_cleanup_policy),
)
.route("/api/cleanup-policy/run-now", post(api_run_cleanup_now))
// ── REST API reference page ────────────────────────────────────────────
.route("/api-docs", get(api_docs_handler))
// ── Prometheus metrics — behind API-key auth ───────────────────────────
.route("/metrics", get(metrics_handler))
.route_layer(middleware::from_fn_with_state(
state.clone(),
auth::require_api_key,
));
protected
.route("/healthz", get(healthz))
.route("/api/health", get(healthz))
.route("/api/version", get(api_version_handler))
.route("/api/openapi.yaml", get(openapi_yaml_handler))
.route("/llms.txt", get(llms_txt_handler))
.route("/llms-full.txt", get(llms_full_txt_handler))
.route("/badge/{metric}", get(badge_handler))
.route("/static/chart.js", get(chart_js_handler))
.route("/static/chart-report.js", get(report_chart_js_handler))
.route("/auth/login", get(auth::auth_login_get))
.route("/auth/login", post(auth::auth_login_post))
// Webhook receivers are public (no API-key auth) — they use per-schedule HMAC secrets.
// Explicit 512 KB body cap: generous for any real webhook payload, blocks body-flood attacks.
.route(
"/webhooks/github",
post(git_webhook::handle_github_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
)
.route(
"/webhooks/gitlab",
post(git_webhook::handle_gitlab_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
)
.route(
"/webhooks/bitbucket",
post(git_webhook::handle_bitbucket_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
)
.layer(middleware::from_fn_with_state(state.clone(), rate_limit))
.layer(middleware::from_fn_with_state(
state.clone(),
add_security_headers,
))
.layer(build_cors_layer(state.server_mode))
.layer(DefaultBodyLimit::max(10 * 1024 * 1024))
.with_state(state)
}
/// Build a minimal router suitable for integration tests — no TCP binding, no API keys, no TLS.
pub fn make_test_router() -> Router {
// Suppress native OS dialogs (file pickers, open-path) during tests.
std::env::set_var("SLOC_HEADLESS", "1");
let tmp = std::env::temp_dir().join("sloc_test");
let state = AppState {
base_config: AppConfig::default(),
artifacts: Arc::new(Mutex::new(HashMap::new())),
async_runs: Arc::new(Mutex::new(HashMap::new())),
registry: Arc::new(Mutex::new(ScanRegistry::default())),
registry_path: tmp.join("registry.json"),
analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
server_mode: false,
tls_enabled: false,
api_keys: Arc::new(vec![]),
rate_limiter: Arc::new(IpRateLimiter::new(
Duration::from_mins(1),
600,
10,
Duration::from_hours(1),
)),
trust_proxy: false,
trusted_proxy_ips: vec![],
git_clones_dir: tmp.join("git-clones"),
schedules: Arc::new(Mutex::new(ScheduleStore::default())),
schedules_path: tmp.join("schedules.json"),
scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
scan_profiles_path: tmp.join("scan_profiles.json"),
sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
confluence_path: tmp.join("confluence_config.json"),
watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
watched_dirs_path: tmp.join("watched_dirs.json"),
cleanup_policy: Arc::new(Mutex::new(CleanupPolicyStore::default())),
cleanup_policy_path: tmp.join("cleanup_policy.json"),
cleanup_task_handle: Arc::new(Mutex::new(None)),
};
build_router(state)
}
/// Test router with one API key pre-loaded. Used by auth integration tests.
pub fn make_test_router_with_key(api_key: &str) -> Router {
let tmp = std::env::temp_dir().join("sloc_test_key");
let state = AppState {
base_config: AppConfig::default(),
artifacts: Arc::new(Mutex::new(HashMap::new())),
async_runs: Arc::new(Mutex::new(HashMap::new())),
registry: Arc::new(Mutex::new(ScanRegistry::default())),
registry_path: tmp.join("registry.json"),
analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
server_mode: false,
tls_enabled: false,
api_keys: Arc::new(vec![secrecy::SecretBox::new(Box::new(api_key.to_owned()))]),
rate_limiter: Arc::new(IpRateLimiter::new(
Duration::from_mins(1),
600,
10,
Duration::from_hours(1),
)),
trust_proxy: false,
trusted_proxy_ips: vec![],
git_clones_dir: tmp.join("git-clones"),
schedules: Arc::new(Mutex::new(ScheduleStore::default())),
schedules_path: tmp.join("schedules.json"),
scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
scan_profiles_path: tmp.join("scan_profiles.json"),
sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
confluence_path: tmp.join("confluence_config.json"),
watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
watched_dirs_path: tmp.join("watched_dirs.json"),
cleanup_policy: Arc::new(Mutex::new(CleanupPolicyStore::default())),
cleanup_policy_path: tmp.join("cleanup_policy.json"),
cleanup_task_handle: Arc::new(Mutex::new(None)),
};
build_router(state)
}
/// Test router with `server_mode = true`. Exercises server-mode-gated code paths such as
/// the locked watched-bar in trend-reports, path validation in analyze, and upload-only
/// preview restrictions.
pub fn make_test_router_server_mode() -> Router {
std::env::set_var("SLOC_HEADLESS", "1");
let tmp = std::env::temp_dir().join("sloc_test_server");
let state = AppState {
base_config: AppConfig::default(),
artifacts: Arc::new(Mutex::new(HashMap::new())),
async_runs: Arc::new(Mutex::new(HashMap::new())),
registry: Arc::new(Mutex::new(ScanRegistry::default())),
registry_path: tmp.join("registry.json"),
analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
server_mode: true,
tls_enabled: false,
api_keys: Arc::new(vec![]),
rate_limiter: Arc::new(IpRateLimiter::new(
Duration::from_mins(1),
600,
10,
Duration::from_hours(1),
)),
trust_proxy: false,
trusted_proxy_ips: vec![],
git_clones_dir: tmp.join("git-clones"),
schedules: Arc::new(Mutex::new(ScheduleStore::default())),
schedules_path: tmp.join("schedules.json"),
scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
scan_profiles_path: tmp.join("scan_profiles.json"),
sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
confluence_path: tmp.join("confluence_config.json"),
watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
watched_dirs_path: tmp.join("watched_dirs.json"),
cleanup_policy: Arc::new(Mutex::new(CleanupPolicyStore::default())),
cleanup_policy_path: tmp.join("cleanup_policy.json"),
cleanup_task_handle: Arc::new(Mutex::new(None)),
};
build_router(state)
}
/// Test router where the analysis semaphore is pre-exhausted (0 permits).
/// Immediately returns 503 on POST /analyze, exercising the busy-server branch.
pub fn make_test_router_exhausted_semaphore() -> Router {
std::env::set_var("SLOC_HEADLESS", "1");
let tmp = std::env::temp_dir().join("sloc_test_exhaust");
let sem = Arc::new(tokio::sync::Semaphore::new(0));
let state = AppState {
base_config: AppConfig::default(),
artifacts: Arc::new(Mutex::new(HashMap::new())),
async_runs: Arc::new(Mutex::new(HashMap::new())),
registry: Arc::new(Mutex::new(ScanRegistry::default())),
registry_path: tmp.join("registry.json"),
analyze_semaphore: sem,
server_mode: false,
tls_enabled: false,
api_keys: Arc::new(vec![]),
rate_limiter: Arc::new(IpRateLimiter::new(
Duration::from_mins(1),
600,
10,
Duration::from_hours(1),
)),
trust_proxy: false,
trusted_proxy_ips: vec![],
git_clones_dir: tmp.join("git-clones"),
schedules: Arc::new(Mutex::new(ScheduleStore::default())),
schedules_path: tmp.join("schedules.json"),
scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
scan_profiles_path: tmp.join("scan_profiles.json"),
sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
confluence_path: tmp.join("confluence_config.json"),
watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
watched_dirs_path: tmp.join("watched_dirs.json"),
cleanup_policy: Arc::new(Mutex::new(CleanupPolicyStore::default())),
cleanup_policy_path: tmp.join("cleanup_policy.json"),
cleanup_task_handle: Arc::new(Mutex::new(None)),
};
build_router(state)
}
/// Test router with a very tight rate limit (3 req/min). The third request from
/// the same IP (0.0.0.0 when `ConnectInfo` is absent) returns 429.
pub fn make_test_router_tight_rate_limit() -> Router {
std::env::set_var("SLOC_HEADLESS", "1");
let tmp = std::env::temp_dir().join("sloc_test_rate");
let state = AppState {
base_config: AppConfig::default(),
artifacts: Arc::new(Mutex::new(HashMap::new())),
async_runs: Arc::new(Mutex::new(HashMap::new())),
registry: Arc::new(Mutex::new(ScanRegistry::default())),
registry_path: tmp.join("registry.json"),
analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
server_mode: false,
tls_enabled: false,
api_keys: Arc::new(vec![]),
rate_limiter: Arc::new(IpRateLimiter::new(
Duration::from_mins(1),
2,
5,
Duration::from_secs(5),
)),
trust_proxy: false,
trusted_proxy_ips: vec![],
git_clones_dir: tmp.join("git-clones"),
schedules: Arc::new(Mutex::new(ScheduleStore::default())),
schedules_path: tmp.join("schedules.json"),
scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
scan_profiles_path: tmp.join("scan_profiles.json"),
sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
confluence_path: tmp.join("confluence_config.json"),
watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
watched_dirs_path: tmp.join("watched_dirs.json"),
cleanup_policy: Arc::new(Mutex::new(CleanupPolicyStore::default())),
cleanup_policy_path: tmp.join("cleanup_policy.json"),
cleanup_task_handle: Arc::new(Mutex::new(None)),
};
build_router(state)
}
struct RuntimeSecurityConfig {
api_keys: Vec<secrecy::SecretBox<String>>,
tls_cert: Option<String>,
tls_key: Option<String>,
tls_enabled: bool,
trust_proxy: bool,
trusted_proxy_ips: Vec<IpAddr>,
rate_limiter: Arc<IpRateLimiter>,
}
fn load_runtime_security_config(server_mode: bool) -> RuntimeSecurityConfig {
let api_keys: Vec<secrecy::SecretBox<String>> = std::env::var("SLOC_API_KEYS")
.or_else(|_| std::env::var("SLOC_API_KEY"))
.unwrap_or_default()
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| secrecy::SecretBox::new(Box::new(s.to_owned())))
.collect();
if server_mode && api_keys.is_empty() {
println!(
"WARNING: SLOC_API_KEY / SLOC_API_KEYS is not set. All web endpoints are \
unauthenticated. Set SLOC_API_KEYS (comma-separated) to enable authentication."
);
}
let tls_cert = std::env::var("SLOC_TLS_CERT").ok();
let tls_key = std::env::var("SLOC_TLS_KEY").ok();
let tls_enabled = tls_cert.is_some() && tls_key.is_some();
if server_mode && !tls_enabled {
println!(
"WARNING: TLS is not configured. Traffic is cleartext. \
Set SLOC_TLS_CERT and SLOC_TLS_KEY for HTTPS, \
or terminate TLS at a reverse proxy (nginx, caddy)."
);
}
if server_mode {
println!(
"CORS: set SLOC_ALLOWED_ORIGINS=https://ci.example.com,https://app.example.com \
to restrict cross-origin access (comma-separated)."
);
}
let trust_proxy = std::env::var("SLOC_TRUST_PROXY").as_deref() == Ok("1");
let trusted_proxy_ips: Vec<IpAddr> = std::env::var("SLOC_TRUSTED_PROXY_IPS")
.unwrap_or_default()
.split(',')
.filter_map(|s| s.trim().parse::<IpAddr>().ok())
.collect();
if trust_proxy {
if trusted_proxy_ips.is_empty() {
println!(
"WARNING: SLOC_TRUST_PROXY=1 but SLOC_TRUSTED_PROXY_IPS is not set. \
X-Forwarded-For will NOT be trusted until you specify the proxy IP(s) via \
SLOC_TRUSTED_PROXY_IPS=192.168.1.1,10.0.0.1 to prevent rate-limit bypass."
);
} else {
println!(
"NOTE: SLOC_TRUST_PROXY=1 — X-Forwarded-For is trusted from proxy IPs: {}",
trusted_proxy_ips
.iter()
.map(std::string::ToString::to_string)
.collect::<Vec<_>>()
.join(", ")
);
}
} else if server_mode {
println!(
"NOTE: SLOC_TRUST_PROXY is not set. If oxide-sloc is behind a reverse proxy \
(nginx, Caddy, Traefik), all LAN clients share one rate-limit bucket (the \
proxy IP). Set SLOC_TRUST_PROXY=1 and SLOC_TRUSTED_PROXY_IPS=<proxy-ip> to \
enable per-client rate limiting via X-Forwarded-For."
);
}
if std::env::var_os("SLOC_GIT_SSL_NO_VERIFY").is_some() {
println!(
"WARNING: SLOC_GIT_SSL_NO_VERIFY is set — TLS certificate verification is \
DISABLED for all git operations. Remove this variable before production use."
);
}
let auth_lockout_threshold = std::env::var("SLOC_AUTH_LOCKOUT_FAILS")
.ok()
.and_then(|v| v.parse::<u32>().ok())
.unwrap_or(10);
let auth_lockout_secs = std::env::var("SLOC_AUTH_LOCKOUT_SECS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(3600);
// Default: 600 req/min in local mode (suits air-gapped/single-user use),
// 120 req/min in server mode (shared network — reduce fuzzing exposure).
// Override with SLOC_RATE_LIMIT=<requests_per_minute>.
let default_rpm: usize = if server_mode { 120 } else { 600 };
let rate_limit_rpm = std::env::var("SLOC_RATE_LIMIT")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(default_rpm);
let rate_limiter = Arc::new(IpRateLimiter::new(
Duration::from_mins(1),
rate_limit_rpm,
auth_lockout_threshold,
Duration::from_secs(auth_lockout_secs),
));
IpRateLimiter::spawn_pruning_task(Arc::clone(&rate_limiter));
RuntimeSecurityConfig {
api_keys,
tls_cert,
tls_key,
tls_enabled,
trust_proxy,
trusted_proxy_ips,
rate_limiter,
}
}
/// # Errors
///
/// Returns an error if the server fails to bind to the configured address or
/// if the TLS configuration cannot be loaded.
///
/// # Panics
///
/// Panics if the Axum router fails to build (only occurs on misconfigured routes).
#[allow(clippy::too_many_lines)]
pub async fn serve(config: AppConfig) -> Result<()> {
let bind_address = config.web.bind_address.clone();
let server_mode = config.web.server_mode;
let output_root = resolve_output_root(None);
// SLOC_REGISTRY_PATH overrides the registry location — useful for shared drives/mounts.
let registry_path = std::env::var("SLOC_REGISTRY_PATH")
.map_or_else(|_| output_root.join("registry.json"), PathBuf::from);
let mut registry = ScanRegistry::load(®istry_path);
registry.prune_stale();
let _ = registry.save(®istry_path);
let sec = load_runtime_security_config(server_mode);
spawn_upload_staging_cleanup();
let git_clones_dir = resolve_git_clones_dir(&output_root);
let schedules_path = std::env::var("SLOC_SCHEDULES_PATH")
.map_or_else(|_| output_root.join("schedules.json"), PathBuf::from);
let schedules = ScheduleStore::load(&schedules_path);
let scan_profiles_path = std::env::var("SLOC_SCAN_PROFILES_PATH")
.map_or_else(|_| output_root.join("scan_profiles.json"), PathBuf::from);
let scan_profiles = ScanProfileStore::load(&scan_profiles_path);
let confluence_path = std::env::var("SLOC_CONFLUENCE_CONFIG_PATH").map_or_else(
|_| output_root.join("confluence_config.json"),
PathBuf::from,
);
let confluence = confluence::ConfluenceConfigStore::load(&confluence_path);
let watched_dirs_path = std::env::var("SLOC_WATCHED_DIRS_PATH")
.map_or_else(|_| output_root.join("watched_dirs.json"), PathBuf::from);
let watched_dirs = WatchedDirsStore::load(&watched_dirs_path);
let cleanup_policy_path = std::env::var("SLOC_CLEANUP_POLICY_PATH")
.map_or_else(|_| output_root.join("cleanup_policy.json"), PathBuf::from);
let cleanup_policy = CleanupPolicyStore::load(&cleanup_policy_path);
let state = AppState {
base_config: config,
artifacts: Arc::new(Mutex::new(HashMap::new())),
async_runs: Arc::new(Mutex::new(HashMap::new())),
registry: Arc::new(Mutex::new(registry)),
registry_path,
analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
server_mode,
tls_enabled: sec.tls_enabled,
api_keys: Arc::new(sec.api_keys),
rate_limiter: sec.rate_limiter,
trust_proxy: sec.trust_proxy,
trusted_proxy_ips: sec.trusted_proxy_ips,
git_clones_dir,
schedules: Arc::new(Mutex::new(schedules)),
schedules_path,
scan_profiles: Arc::new(Mutex::new(scan_profiles)),
scan_profiles_path,
sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
confluence: Arc::new(Mutex::new(confluence)),
confluence_path,
watched_dirs: Arc::new(Mutex::new(watched_dirs)),
watched_dirs_path,
cleanup_policy: Arc::new(Mutex::new(cleanup_policy)),
cleanup_policy_path,
cleanup_task_handle: Arc::new(Mutex::new(None)),
};
restart_poll_schedules(&state).await;
// Restart auto-cleanup task if a policy was previously saved and is enabled.
{
let enabled = state
.cleanup_policy
.lock()
.await
.policy
.as_ref()
.is_some_and(|p| p.enabled);
if enabled {
let handle = spawn_cleanup_policy_task(state.clone());
*state.cleanup_task_handle.lock().await = Some(handle);
}
}
let app = build_router(state.clone());
// Try the configured port first, then step up through a few alternatives.
// On Windows, a killed process can leave its LISTEN socket as an unkillable
// kernel zombie (visible in netstat but owned by no living process). Rather
// than failing, we auto-select the next free port and tell the user.
let preferred: SocketAddr = bind_address
.parse()
.with_context(|| format!("invalid bind address: {bind_address}"))?;
let (listener, addr) = {
let candidates = (0u16..=9).map(|offset| {
let mut a = preferred;
a.set_port(preferred.port().saturating_add(offset));
a
});
let mut found = None;
for candidate in candidates {
if let Ok(l) = tokio::net::TcpListener::bind(candidate).await {
found = Some((l, candidate));
break;
}
}
found.ok_or_else(|| {
anyhow::anyhow!(
"failed to bind local web UI on {} (tried ports {}-{}): all in use",
bind_address,
preferred.port(),
preferred.port().saturating_add(9)
)
})?
};
if addr != preferred {
eprintln!(
"NOTE: port {} is blocked by a system socket (Windows zombie); \
using {} instead.",
preferred.port(),
addr.port()
);
}
if sec.tls_enabled {
let cert_path = sec
.tls_cert
.expect("tls_enabled guarantees SLOC_TLS_CERT is Some");
let key_path = sec
.tls_key
.expect("tls_enabled guarantees SLOC_TLS_KEY is Some");
let tls_config = build_tls_config(&cert_path, &key_path)
.context("failed to load TLS certificate/key")?;
let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
let url = format!("https://{addr}/");
println!("OxideSLOC server running at {url} (TLS)");
println!("Use Ctrl+C to stop.");
return serve_tls(listener, app, acceptor, server_mode).await;
}
let url = format!("http://{addr}/");
log_startup_url(&url, server_mode);
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(shutdown_signal(server_mode))
.await
.context("web server terminated unexpectedly")
}
/// Discover the primary non-loopback IPv4 address by asking the OS which
/// outbound interface it would use to reach a public address. No packets are
/// sent — the UDP socket is only used to query the routing table.
fn primary_lan_ip() -> Option<String> {
let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
socket.connect("8.8.8.8:80").ok()?;
let addr = socket.local_addr().ok()?;
let ip = addr.ip();
if ip.is_loopback() {
return None;
}
Some(ip.to_string())
}
/// Print the startup URL and, in local mode, open the browser and schedule it.
fn log_startup_url(url: &str, server_mode: bool) {
if server_mode {
println!("OxideSLOC server running at {url}");
println!("Use Ctrl+C to stop.");
} else {
println!("OxideSLOC local web UI running at {url}");
println!("Press Ctrl+C to stop the server.");
let open_url = url.to_owned();
tokio::task::spawn_blocking(move || open_browser_tab(&open_url));
}
}
/// Open the given URL in the default system browser.
fn open_browser_tab(url: &str) {
#[cfg(target_os = "windows")]
let _ = std::process::Command::new("cmd")
.args(["/c", "start", "", url])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
#[cfg(target_os = "macos")]
let _ = std::process::Command::new("open")
.arg(url)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
#[cfg(target_os = "linux")]
let _ = std::process::Command::new("xdg-open")
.arg(url)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
}
/// Graceful-shutdown future: resolves on Ctrl-C.
async fn shutdown_signal(server_mode: bool) {
if tokio::signal::ctrl_c().await.is_ok() {
println!();
if server_mode {
println!("Shutting down OxideSLOC server...");
} else {
println!("Shutting down OxideSLOC local web UI...");
}
println!("Server stopped cleanly.");
}
}
/// Load a rustls `ServerConfig` from PEM certificate and key files.
fn build_tls_config(cert_path: &str, key_path: &str) -> Result<rustls::ServerConfig> {
use rustls_pki_types::pem::PemObject;
use rustls_pki_types::{CertificateDer, PrivateKeyDer};
let cert_bytes =
fs::read(cert_path).with_context(|| format!("failed to read TLS cert: {cert_path}"))?;
let key_bytes =
fs::read(key_path).with_context(|| format!("failed to read TLS key: {key_path}"))?;
let cert_chain: Vec<CertificateDer<'static>> =
CertificateDer::pem_slice_iter(cert_bytes.as_slice())
.collect::<std::result::Result<_, _>>()
.context("failed to parse TLS certificates")?;
let key = PrivateKeyDer::from_pem_slice(key_bytes.as_slice())
.context("failed to parse TLS private key")?;
rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(cert_chain, key)
.context("failed to build TLS server config")
}
/// Accept loop with TLS termination using tokio-rustls + hyper-util.
async fn serve_tls(
listener: tokio::net::TcpListener,
app: Router,
acceptor: tokio_rustls::TlsAcceptor,
server_mode: bool,
) -> Result<()> {
use hyper_util::rt::{TokioExecutor, TokioIo};
use hyper_util::server::conn::auto::Builder as ConnBuilder;
use hyper_util::service::TowerToHyperService;
use tower::{Service, ServiceExt};
let make_svc = app.into_make_service_with_connect_info::<SocketAddr>();
loop {
tokio::select! {
biased;
_ = tokio::signal::ctrl_c() => {
println!();
if server_mode {
println!("Shutting down OxideSLOC server...");
} else {
println!("Shutting down OxideSLOC local web UI...");
}
println!("Server stopped cleanly.");
return Ok(());
}
result = listener.accept() => {
let (tcp, peer_addr) = result.context("TLS accept failed")?;
let acceptor = acceptor.clone();
let mut factory = make_svc.clone();
tokio::spawn(async move {
let tls = match acceptor.accept(tcp).await {
Ok(s) => s,
Err(e) => {
eprintln!("[sloc-web] TLS handshake from {peer_addr}: {e}");
return;
}
};
let svc = match ServiceExt::<SocketAddr>::ready(&mut factory).await {
Ok(f) => match Service::call(f, peer_addr).await {
Ok(s) => s,
Err(_) => return,
},
Err(_) => return,
};
let io = TokioIo::new(tls);
if let Err(e) = ConnBuilder::new(TokioExecutor::new())
.serve_connection(io, TowerToHyperService::new(svc))
.await
{
eprintln!("[sloc-web] connection error from {peer_addr}: {e}");
}
});
}
}
}
}
// auth moved to auth.rs
fn build_cors_layer(server_mode: bool) -> CorsLayer {
if server_mode {
let allowed: Vec<axum::http::HeaderValue> = std::env::var("SLOC_ALLOWED_ORIGINS")
.unwrap_or_default()
.split(',')
.filter(|s| !s.is_empty())
.filter_map(|s| s.trim().parse().ok())
.collect();
if allowed.is_empty() {
return CorsLayer::new();
}
CorsLayer::new()
.allow_origin(AllowOrigin::list(allowed))
.allow_methods(AllowMethods::list([
axum::http::Method::GET,
axum::http::Method::POST,
]))
.allow_headers(AllowHeaders::list([
axum::http::header::AUTHORIZATION,
axum::http::header::CONTENT_TYPE,
]))
} else {
CorsLayer::new().allow_origin(AllowOrigin::predicate(|origin, _| {
let s = origin.to_str().unwrap_or("");
s.starts_with("http://127.0.0.1:") || s.starts_with("http://localhost:")
}))
}
}
async fn add_security_headers(
State(state): State<AppState>,
mut req: Request<Body>,
next: Next,
) -> Response {
let nonce = uuid::Uuid::new_v4().to_string().replace('-', "");
req.extensions_mut().insert(CspNonce(nonce.clone()));
let mut resp = next.run(req).await;
let h = resp.headers_mut();
h.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
h.insert(
"X-Content-Type-Options",
HeaderValue::from_static("nosniff"),
);
h.insert(
"Referrer-Policy",
HeaderValue::from_static("strict-origin-when-cross-origin"),
);
let csp = format!(
"default-src 'self'; \
style-src 'self' 'unsafe-inline'; \
img-src 'self' data: blob:; \
script-src 'self' 'nonce-{nonce}'; \
font-src 'self' data:; \
object-src 'none'; \
frame-ancestors 'none'"
);
h.insert(
"Content-Security-Policy",
HeaderValue::from_str(&csp).unwrap_or_else(|_| {
HeaderValue::from_static(
"default-src 'self'; object-src 'none'; frame-ancestors 'none'",
)
}),
);
h.insert(
"X-Permitted-Cross-Domain-Policies",
HeaderValue::from_static("none"),
);
h.insert(
"Permissions-Policy",
HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"),
);
h.insert(
"Cross-Origin-Opener-Policy",
HeaderValue::from_static("same-origin"),
);
h.insert(
"Cross-Origin-Resource-Policy",
HeaderValue::from_static("same-origin"),
);
if state.tls_enabled {
h.insert(
"Strict-Transport-Security",
HeaderValue::from_static("max-age=31536000; includeSubDomains"),
);
}
resp
}
async fn rate_limit(State(state): State<AppState>, req: Request<Body>, next: Next) -> Response {
let peer_ip = req
.extensions()
.get::<axum::extract::ConnectInfo<SocketAddr>>()
.map(|c| c.0.ip());
// Only honour X-Forwarded-For when trust_proxy is on AND the TCP peer is in the
// explicitly configured trusted-proxy allowlist. This prevents rate-limit bypass via
// header spoofing from direct connections.
let ip = peer_ip
.and_then(|peer| {
if state.trust_proxy && state.trusted_proxy_ips.contains(&peer) {
req.headers()
.get("X-Forwarded-For")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.split(',').next())
.and_then(|s| s.trim().parse::<IpAddr>().ok())
} else {
None
}
})
.or(peer_ip)
.unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
if !state.rate_limiter.is_allowed(ip) {
tracing::warn!(event = "rate_limit_hit", peer_addr = %ip,
path = %req.uri().path(), "Rate limit exceeded");
return (
StatusCode::TOO_MANY_REQUESTS,
[(header::RETRY_AFTER, "60")],
"429 Too Many Requests\n",
)
.into_response();
}
next.run(req).await
}
async fn splash(
State(state): State<AppState>,
axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
) -> impl IntoResponse {
let lan_ip = if state.server_mode {
primary_lan_ip()
} else {
None
};
let port = state
.base_config
.web
.bind_address
.rsplit(':')
.next()
.and_then(|p| p.parse::<u16>().ok())
.unwrap_or(4317);
let has_api_key = !state.api_keys.is_empty();
let template = SplashTemplate {
csp_nonce,
server_mode: state.server_mode,
lan_ip,
port,
version: env!("CARGO_PKG_VERSION"),
has_api_key,
};
Html(
template
.render()
.unwrap_or_else(|err| format!("<pre>{err}</pre>")),
)
}
async fn index(
State(state): State<AppState>,
axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
Query(query): Query<IndexQuery>,
) -> impl IntoResponse {
let prefill_json = if query.prefilled.as_deref() == Some("1") || query.path.is_some() {
let policy = query
.mixed_line_policy
.unwrap_or_else(|| "code_only".to_string());
let behavior = query
.binary_file_behavior
.unwrap_or_else(|| "skip".to_string());
let cfg = ScanConfig {
oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
path: query.path.unwrap_or_default(),
include_globs: query.include_globs.unwrap_or_default(),
exclude_globs: query.exclude_globs.unwrap_or_default(),
submodule_breakdown: query.submodule_breakdown.as_deref() == Some("enabled"),
mixed_line_policy: policy,
python_docstrings_as_comments: query.python_docstrings_as_comments.as_deref()
!= Some("off"),
generated_file_detection: query.generated_file_detection.as_deref() != Some("disabled"),
minified_file_detection: query.minified_file_detection.as_deref() != Some("disabled"),
vendor_directory_detection: query.vendor_directory_detection.as_deref()
!= Some("disabled"),
include_lockfiles: query.include_lockfiles.as_deref() == Some("enabled"),
binary_file_behavior: behavior,
output_dir: query.output_dir.unwrap_or_default(),
report_title: query.report_title.unwrap_or_default(),
};
serde_json::to_string(&cfg).unwrap_or_else(|_| "{}".to_string())
} else {
"{}".to_string()
};
let git_repo = query.git_repo.unwrap_or_default();
let git_ref = query.git_ref.unwrap_or_default();
let git_label = make_git_label(&git_repo, &git_ref);
let git_output_dir = if git_label.is_empty() {
String::new()
} else {
desktop_dir().join(&git_label).display().to_string()
};
let git_label_json = serde_json::to_string(&git_label).unwrap_or_else(|_| "\"\"".to_owned());
let git_output_dir_json =
serde_json::to_string(&git_output_dir).unwrap_or_else(|_| "\"\"".to_owned());
let template = IndexTemplate {
version: env!("CARGO_PKG_VERSION"),
prefill_json,
csp_nonce,
git_repo,
git_ref,
git_label_json,
git_output_dir_json,
server_mode: state.server_mode,
};
Html(
template
.render()
.unwrap_or_else(|err| format!("<pre>{err}</pre>")),
)
}
async fn scan_setup_handler(
State(state): State<AppState>,
axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
) -> impl IntoResponse {
let recent_scans_json = {
let arr: Vec<serde_json::Value> = {
let reg = state.registry.lock().await;
reg.entries
.iter()
.rev()
.take(6)
.map(|e| {
let run_dir = e
.html_path
.as_ref()
.or(e.json_path.as_ref())
.and_then(|p| p.parent().map(PathBuf::from));
let config_val: Option<serde_json::Value> = run_dir
.and_then(|d| find_scan_config_in_dir(&d))
.and_then(|p| fs::read_to_string(&p).ok())
.and_then(|s| serde_json::from_str(&s).ok());
serde_json::json!({
"project_label": e.project_label,
"timestamp": fmt_la_time(e.timestamp_utc),
"path": e.input_roots.first().map(|s| sanitize_path_str(s)).unwrap_or_default(),
"config": config_val,
})
})
.collect()
};
serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
};
let template = ScanSetupTemplate {
version: env!("CARGO_PKG_VERSION"),
recent_scans_json,
csp_nonce,
};
Html(
template
.render()
.unwrap_or_else(|err| format!("<pre>{err}</pre>")),
)
}
async fn healthz() -> &'static str {
"ok"
}
async fn api_version_handler() -> impl IntoResponse {
axum::Json(serde_json::json!({
"name": "oxide-sloc",
"version": env!("CARGO_PKG_VERSION"),
}))
}
// ── Prometheus metrics ────────────────────────────────────────────────────────
fn prom_runs_total() -> &'static prometheus::IntCounter {
static COUNTER: OnceLock<prometheus::IntCounter> = OnceLock::new();
COUNTER.get_or_init(|| {
prometheus::register_int_counter!(
"oxide_sloc_runs_total",
"Total number of completed analysis runs"
)
.expect("failed to register oxide_sloc_runs_total counter")
})
}
async fn metrics_handler() -> impl IntoResponse {
use prometheus::Encoder as _;
let mut buf = Vec::new();
let encoder = prometheus::TextEncoder::new();
let _ = encoder.encode(&prometheus::gather(), &mut buf);
(
[(
axum::http::header::CONTENT_TYPE,
"text/plain; version=0.0.4; charset=utf-8",
)],
buf,
)
}
static OPENAPI_YAML: &str = include_str!("../assets/openapi.yaml");
async fn openapi_yaml_handler() -> impl IntoResponse {
(
[(axum::http::header::CONTENT_TYPE, "application/yaml")],
OPENAPI_YAML,
)
}
static LLMS_TXT: &str = include_str!("../assets/ai/llms.txt");
static LLMS_FULL_TXT: &str = include_str!("../assets/ai/llms-full.txt");
async fn llms_txt_handler() -> impl IntoResponse {
(
[
(
axum::http::header::CONTENT_TYPE,
"text/plain; charset=utf-8",
),
(axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
],
LLMS_TXT,
)
}
async fn llms_full_txt_handler() -> impl IntoResponse {
(
[
(
axum::http::header::CONTENT_TYPE,
"text/plain; charset=utf-8",
),
(axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
],
LLMS_FULL_TXT,
)
}
async fn api_docs_handler(
State(state): State<AppState>,
axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
) -> impl IntoResponse {
let has_api_key = !state.api_keys.is_empty();
Html(
ApiDocsTemplate {
has_api_key,
csp_nonce,
version: env!("CARGO_PKG_VERSION"),
}
.render()
.unwrap_or_else(|e| format!("<pre>{e}</pre>")),
)
}
async fn chart_js_handler() -> impl IntoResponse {
(
[
(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
),
(header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
],
CHART_JS,
)
}
async fn report_chart_js_handler() -> impl IntoResponse {
(
[
(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
),
(header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
],
REPORT_CHART_JS,
)
}
#[derive(Debug, Deserialize)]
struct AnalyzeForm {
path: String,
git_repo: Option<String>,
git_ref: Option<String>,
mixed_line_policy: Option<MixedLinePolicy>,
python_docstrings_as_comments: Option<String>,
generated_file_detection: Option<String>,
minified_file_detection: Option<String>,
vendor_directory_detection: Option<String>,
include_lockfiles: Option<String>,
binary_file_behavior: Option<BinaryFileBehavior>,
output_dir: Option<String>,
report_title: Option<String>,
report_header_footer: Option<String>,
include_globs: Option<String>,
exclude_globs: Option<String>,
submodule_breakdown: Option<String>,
coverage_file: Option<String>,
continuation_line_policy: Option<ContinuationLinePolicy>,
blank_in_block_comment_policy: Option<BlankInBlockCommentPolicy>,
count_compiler_directives: Option<String>,
style_col_threshold: Option<String>,
style_analysis_enabled: Option<String>,
style_score_threshold: Option<String>,
style_lang_scope: Option<String>,
/// COCOMO I mode (`organic` | `semi_detached` | `embedded`). Defaults to organic.
cocomo_mode: Option<String>,
/// Cyclomatic complexity alert threshold. Files above this are highlighted. Empty = off.
complexity_alert: Option<String>,
/// Whether to exclude duplicate files from displayed SLOC totals.
exclude_duplicates: Option<String>,
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Serialize, Deserialize, Clone)]
struct ScanConfig {
oxide_sloc_version: String,
path: String,
include_globs: String,
exclude_globs: String,
submodule_breakdown: bool,
mixed_line_policy: String,
python_docstrings_as_comments: bool,
generated_file_detection: bool,
minified_file_detection: bool,
vendor_directory_detection: bool,
include_lockfiles: bool,
binary_file_behavior: String,
output_dir: String,
report_title: String,
}
#[derive(Debug, Deserialize, Default)]
struct IndexQuery {
path: Option<String>,
include_globs: Option<String>,
exclude_globs: Option<String>,
submodule_breakdown: Option<String>,
mixed_line_policy: Option<String>,
python_docstrings_as_comments: Option<String>,
generated_file_detection: Option<String>,
minified_file_detection: Option<String>,
vendor_directory_detection: Option<String>,
include_lockfiles: Option<String>,
binary_file_behavior: Option<String>,
output_dir: Option<String>,
report_title: Option<String>,
prefilled: Option<String>,
git_repo: Option<String>,
git_ref: Option<String>,
}
#[derive(Debug, Deserialize)]
struct PreviewQuery {
path: Option<String>,
include_globs: Option<String>,
exclude_globs: Option<String>,
}
#[cfg(feature = "native-dialog")]
#[derive(Debug, Deserialize)]
struct PickDirectoryQuery {
kind: Option<String>,
current: Option<String>,
}
#[cfg(not(feature = "native-dialog"))]
#[derive(Debug, Deserialize)]
struct PickDirectoryQuery {}
#[derive(Debug, Deserialize, Default)]
struct ArtifactQuery {
download: Option<String>,
}
#[cfg(feature = "native-dialog")]
#[derive(Debug, Serialize)]
struct PickDirectoryResponse {
selected_path: Option<String>,
cancelled: bool,
}
#[cfg(feature = "native-dialog")]
async fn pick_directory_handler(
State(state): State<AppState>,
Query(query): Query<PickDirectoryQuery>,
) -> Response {
if state.server_mode {
return StatusCode::NOT_FOUND.into_response();
}
// Return immediately without opening a dialog in headless / CI environments.
if std::env::var("SLOC_HEADLESS").is_ok() {
return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
.into_response();
}
let is_coverage = query.kind.as_deref() == Some("coverage");
let title = match query.kind.as_deref() {
Some("output") => "Select output directory",
Some("reports") => "Select folder containing saved reports",
Some("coverage") => "Select LCOV coverage file",
_ => "Select project directory",
}
.to_owned();
let current = query.current.clone();
let picked = tokio::task::spawn_blocking(move || {
// Windows: attach to the foreground thread so the dialog inherits focus,
// and kick off a watcher that flashes the dialog once it appears.
#[cfg(all(target_os = "windows", feature = "native-dialog"))]
let fg_tid = win_dialog_focus::attach_to_foreground();
#[cfg(all(target_os = "windows", feature = "native-dialog"))]
win_dialog_focus::flash_dialog_when_ready(title.clone());
let mut dialog = rfd::FileDialog::new().set_title(&title);
if let Some(current) = current.as_deref() {
let resolved = resolve_input_path(current);
let seed = if resolved.is_dir() {
Some(resolved)
} else {
resolved.parent().map(Path::to_path_buf)
};
if let Some(seed_dir) = seed.filter(|p| p.exists()) {
dialog = dialog.set_directory(seed_dir);
}
}
let result = if is_coverage {
dialog
.add_filter(
"Coverage files (LCOV, Cobertura XML, JaCoCo XML)",
&["info", "lcov", "xml"],
)
.pick_file()
} else {
dialog.pick_folder()
};
#[cfg(all(target_os = "windows", feature = "native-dialog"))]
win_dialog_focus::detach_from_foreground(fg_tid);
result
})
.await
.unwrap_or(None);
Json(PickDirectoryResponse {
selected_path: picked.as_ref().map(|p| display_path(p)),
cancelled: picked.is_none(),
})
.into_response()
}
#[cfg(not(feature = "native-dialog"))]
async fn pick_directory_handler(
State(_state): State<AppState>,
Query(_query): Query<PickDirectoryQuery>,
) -> Response {
Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
}
#[cfg(feature = "native-dialog")]
async fn pick_file_handler(State(state): State<AppState>) -> Response {
if state.server_mode {
return StatusCode::NOT_FOUND.into_response();
}
if std::env::var("SLOC_HEADLESS").is_ok() {
return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
.into_response();
}
let picked = tokio::task::spawn_blocking(|| {
#[cfg(all(target_os = "windows", feature = "native-dialog"))]
let fg_tid = win_dialog_focus::attach_to_foreground();
#[cfg(all(target_os = "windows", feature = "native-dialog"))]
win_dialog_focus::flash_dialog_when_ready("Select HTML report".to_owned());
let result = rfd::FileDialog::new()
.set_title("Select HTML report")
.add_filter("HTML report", &["html"])
.pick_file();
#[cfg(all(target_os = "windows", feature = "native-dialog"))]
win_dialog_focus::detach_from_foreground(fg_tid);
result
})
.await
.unwrap_or(None);
Json(PickDirectoryResponse {
selected_path: picked.as_ref().map(|p| display_path(p)),
cancelled: picked.is_none(),
})
.into_response()
}
#[cfg(not(feature = "native-dialog"))]
async fn pick_file_handler(State(_state): State<AppState>) -> Response {
Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
}
// ── Browser-upload handlers (server mode only) ────────────────────────────────
/// Returns true when `path` is inside the oxide-sloc temp-upload staging area.
/// Used to bypass `allowed_scan_roots` restrictions for client-uploaded projects.
fn is_upload_tmp_path(path: &Path) -> bool {
let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
path.starts_with(&upload_root)
}
/// Returns true when `path` is the built-in sample or test-fixture directory.
/// These paths ship with the server binary and are always safe to scan/preview.
fn is_sample_path(path: &Path) -> bool {
let root = workspace_root();
path.starts_with(root.join("tests").join("fixtures")) || path.starts_with(root.join("samples"))
}
/// Returns the shared upload base directory: `<tmp>/oxide-sloc-uploads`.
fn upload_base_dir() -> PathBuf {
std::env::temp_dir().join("oxide-sloc-uploads")
}
/// Returns the staging path for a given upload id inside the base dir.
fn upload_staging_path(id: &str) -> PathBuf {
upload_base_dir().join(id)
}
/// Validate basic field constraints on a directory-upload request.
/// Returns an error `Response` if the request should be rejected immediately.
#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
fn validate_upload_dir_request(body: &UploadDirRequest) -> Result<(), Response> {
const MAX_FILES: usize = 50_000;
if body.files.is_empty() {
return Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "No files received"})),
)
.into_response());
}
if body.files.len() > MAX_FILES {
return Err((
StatusCode::PAYLOAD_TOO_LARGE,
Json(serde_json::json!({"error": "Too many files (limit 50 000)"})),
)
.into_response());
}
Ok(())
}
/// Resolve or create the staging directory for a directory upload.
/// Reuses an existing directory when `id` is a valid UUID; otherwise mints a new one.
fn resolve_or_create_staging(id: Option<&str>) -> (String, PathBuf) {
match id {
Some(id)
if !id.is_empty()
&& id.len() <= 36
&& id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') =>
{
(id.to_string(), upload_staging_path(id))
}
_ => {
let new_id = uuid::Uuid::new_v4().to_string();
let staging = upload_staging_path(&new_id);
(new_id, staging)
}
}
}
/// Decode, size-check, and write one uploaded file entry into `staging`.
/// Returns `Ok(())` whether the file was written or skipped (bad base64).
/// Returns `Err(Response)` for fatal errors; the caller is responsible for
/// cleaning up `staging` before propagating the error.
#[allow(clippy::result_large_err)]
async fn stage_decoded_entry(
entry: &UploadedFile,
staging: &Path,
total_bytes: &mut usize,
project_root: &mut Option<PathBuf>,
) -> Result<(), Response> {
const MAX_TOTAL_BYTES: usize = 500 * 1024 * 1024;
let Ok(data) = base64::Engine::decode(
&base64::engine::general_purpose::STANDARD,
entry.content.as_bytes(),
) else {
return Ok(());
};
*total_bytes += data.len();
if *total_bytes > MAX_TOTAL_BYTES {
return Err((
StatusCode::PAYLOAD_TOO_LARGE,
Json(serde_json::json!({"error": "Upload exceeds the 500 MB limit"})),
)
.into_response());
}
let rel = std::path::Path::new(&entry.path);
if project_root.is_none() {
if let Some(first) = rel.components().next() {
*project_root = Some(staging.join(first.as_os_str()));
}
}
let dest = staging.join(rel);
if let Some(parent) = dest.parent() {
if tokio::fs::create_dir_all(parent).await.is_err() {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "Failed to create directory structure"})),
)
.into_response());
}
}
if tokio::fs::write(&dest, &data).await.is_err() {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "Failed to write uploaded file"})),
)
.into_response());
}
Ok(())
}
/// Write a batch of uploaded files into `staging`, enforcing the total-bytes cap
/// and path-traversal guard. Returns `(file_count, project_root)` on success or
/// an error `Response` on failure (staging dir is cleaned up before returning).
async fn write_upload_files(
files: &[UploadedFile],
staging: &Path,
upload_id: &str,
) -> Result<(usize, Option<PathBuf>), Response> {
let mut total_bytes: usize = 0;
let mut project_root: Option<PathBuf> = None;
let mut traversal_attempts: usize = 0;
for entry in files {
let rel = std::path::Path::new(&entry.path);
if rel
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
traversal_attempts += 1;
if traversal_attempts >= 5 {
let _ = tokio::fs::remove_dir_all(staging).await;
tracing::warn!(
event = "upload_path_traversal",
upload_id = %upload_id,
"Upload rejected: repeated path traversal attempts detected"
);
return Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Upload rejected"})),
)
.into_response());
}
continue;
}
if let Err(resp) =
stage_decoded_entry(entry, staging, &mut total_bytes, &mut project_root).await
{
let _ = tokio::fs::remove_dir_all(staging).await;
return Err(resp);
}
}
Ok((files.len(), project_root))
}
/// Read `SLOC_MAX_TARBALL_MB` and `SLOC_MAX_TARBALL_DECOMPRESSED_MB` from the
/// environment and return `(max_compressed_bytes, max_decompressed_bytes)`.
fn parse_tarball_size_caps() -> (u64, u64) {
let compressed = std::env::var("SLOC_MAX_TARBALL_MB")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(2048_u64)
* 1024
* 1024;
let decompressed = std::env::var("SLOC_MAX_TARBALL_DECOMPRESSED_MB")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(10_240_u64)
* 1024
* 1024;
(compressed, decompressed)
}
/// Stream `body` into `dest_path`, enforcing `max_bytes`.
/// Returns the number of compressed bytes written, or an error `Response`.
/// Cleans up `dest_path` on error.
#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
async fn stream_body_to_file(
body: axum::body::Body,
dest_path: &Path,
max_bytes: u64,
) -> Result<u64, Response> {
use http_body_util::BodyExt as _;
use tokio::io::AsyncWriteExt as _;
let mut file = match tokio::fs::File::create(dest_path).await {
Ok(f) => f,
Err(e) => {
tracing::error!(
event = "upload_io_error",
"failed to create tarball temp file: {e}"
);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "Upload initialization failed"})),
)
.into_response());
}
};
let mut body = body;
let mut written: u64 = 0;
loop {
match body.frame().await {
None => break,
Some(Err(e)) => {
let _ = tokio::fs::remove_file(dest_path).await;
return Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": format!("Stream error: {e}")})),
)
.into_response());
}
Some(Ok(frame)) => {
if let Ok(data) = frame.into_data() {
written += data.len() as u64;
if written > max_bytes {
let _ = tokio::fs::remove_file(dest_path).await;
return Err((
StatusCode::PAYLOAD_TOO_LARGE,
Json(serde_json::json!({"error": "Tarball exceeds the allowed size limit"})),
)
.into_response());
}
if let Err(e) = file.write_all(&data).await {
let _ = tokio::fs::remove_file(dest_path).await;
tracing::error!(event = "upload_io_error", "tarball write error: {e}");
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "Upload write failed"})),
)
.into_response());
}
}
}
}
}
drop(file);
Ok(written)
}
/// Extract `tarball_path` (tar.gz) into `staging`, enforcing `max_decompressed_bytes`.
/// Always removes `tarball_path` regardless of outcome. Returns an error `Response`
/// on failure (staging dir is cleaned up before returning).
#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
async fn extract_tarball_to_staging(
tarball_path: &Path,
staging: &Path,
max_decompressed_bytes: u64,
) -> Result<(), Response> {
let staging_clone = staging.to_path_buf();
let tarball_clone = tarball_path.to_path_buf();
let extract_result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
let file = std::fs::File::open(&tarball_clone)?;
let gz = flate2::read::GzDecoder::new(std::io::BufReader::new(file));
let limited = SizeLimitReader {
inner: gz,
remaining: max_decompressed_bytes,
};
let mut archive = tar::Archive::new(limited);
archive.set_overwrite(true);
archive.set_preserve_permissions(false);
std::fs::create_dir_all(&staging_clone)?;
archive.unpack(&staging_clone)?;
Ok(())
})
.await;
let _ = tokio::fs::remove_file(tarball_path).await;
match extract_result {
Ok(Ok(())) => Ok(()),
Ok(Err(e)) => {
let _ = tokio::fs::remove_dir_all(staging).await;
let is_size_limit = e.to_string().contains("decompressed size limit exceeded");
tracing::warn!(
event = "upload_extract_error",
"tarball extraction failed: {e:#}"
);
let (status, msg) = if is_size_limit {
(
StatusCode::PAYLOAD_TOO_LARGE,
"Archive exceeds the decompressed size limit",
)
} else {
(StatusCode::BAD_REQUEST, "Failed to extract archive")
};
Err((status, Json(serde_json::json!({"error": msg}))).into_response())
}
Err(e) => {
let _ = tokio::fs::remove_dir_all(staging).await;
tracing::error!(
event = "upload_extract_panic",
"tarball extraction task panicked: {e}"
);
Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "Archive extraction failed"})),
)
.into_response())
}
}
}
/// If `staging` contains exactly one top-level directory, return its path
/// (the common case when the archive was created with `webkitRelativePath`).
/// Otherwise return `None`.
async fn find_single_top_dir(staging: &Path) -> Option<PathBuf> {
let mut entries = tokio::fs::read_dir(staging).await.ok()?;
let first = entries.next_entry().await.ok()??;
if !first.path().is_dir() {
return None;
}
if entries.next_entry().await.unwrap_or(None).is_some() {
return None;
}
Some(first.path())
}
/// Request body for `POST /api/upload-directory`.
///
/// Each entry carries a relative path (identical to the browser's
/// `File.webkitRelativePath`, e.g. `myproject/src/main.rs`) and the file
/// contents encoded as standard (non-URL-safe) base64. Using JSON + base64
/// avoids pulling in a `multipart` library that is not in the vendor archive.
#[derive(Deserialize)]
struct UploadDirRequest {
files: Vec<UploadedFile>,
/// If provided, append this batch to an existing upload session instead of
/// creating a new staging directory. Must be a plain UUID (no path separators).
upload_id: Option<String>,
}
#[derive(Deserialize)]
struct UploadedFile {
/// `webkitRelativePath` value from the browser File object.
path: String,
/// Raw file bytes encoded as standard base64.
content: String,
}
/// POST /api/upload-directory
///
/// Accepts a JSON body `{ "files": [{ "path": "…", "content": "<base64>" }] }`.
/// Saves all files to a temp staging directory preserving their relative paths,
/// then returns the server-side root directory path so the caller can populate
/// the scan-path field and run a normal analysis.
///
/// Only available in server mode; returns 404 in local mode (use the native
/// rfd dialog instead).
async fn upload_directory_handler(
State(state): State<AppState>,
Json(body): Json<UploadDirRequest>,
) -> Response {
if !state.server_mode {
return StatusCode::NOT_FOUND.into_response();
}
if let Err(resp) = validate_upload_dir_request(&body) {
return resp;
}
// Reuse an existing staging dir when the client sends a continuation batch,
// otherwise create a fresh one. Validate the id to prevent path traversal.
let (upload_id, staging) = resolve_or_create_staging(body.upload_id.as_deref());
match write_upload_files(&body.files, &staging, &upload_id).await {
Ok((file_count, project_root)) => {
let scan_root = project_root.unwrap_or_else(|| staging.clone());
Json(serde_json::json!({
"tmp_path": scan_root.to_string_lossy(),
"file_count": file_count,
"upload_id": upload_id.clone()
}))
.into_response()
}
Err(resp) => resp,
}
}
/// Request body for `POST /api/upload-file`.
#[derive(Deserialize)]
struct UploadFileRequest {
/// Original filename (used only to preserve the extension).
filename: String,
/// File bytes encoded as standard base64.
content: String,
}
/// POST /api/upload-file
///
/// Single-file variant used for coverage files (`.info`, `.lcov`, `.xml`).
/// Accepts `{ "filename": "…", "content": "<base64>" }`.
/// Only available in server mode.
async fn upload_file_handler(
State(state): State<AppState>,
Json(body): Json<UploadFileRequest>,
) -> Response {
const MAX_FILE_BYTES: usize = 10 * 1024 * 1024; // 10 MB (decoded)
if !state.server_mode {
return StatusCode::NOT_FOUND.into_response();
}
let Ok(data) = base64::Engine::decode(
&base64::engine::general_purpose::STANDARD,
body.content.as_bytes(),
) else {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Invalid base64 content"})),
)
.into_response();
};
if data.len() > MAX_FILE_BYTES {
return (
StatusCode::PAYLOAD_TOO_LARGE,
Json(serde_json::json!({"error": "File exceeds the 10 MB limit"})),
)
.into_response();
}
// Sanitise: strip any directory component from the filename.
let filename = std::path::Path::new(&body.filename)
.file_name()
.map_or_else(|| "upload".to_owned(), |n| n.to_string_lossy().into_owned());
let upload_id = uuid::Uuid::new_v4();
let staging = std::env::temp_dir()
.join("oxide-sloc-uploads")
.join(upload_id.to_string());
if tokio::fs::create_dir_all(&staging).await.is_err() {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "Failed to create staging directory"})),
)
.into_response();
}
let dest = staging.join(&filename);
if tokio::fs::write(&dest, &data).await.is_err() {
let _ = tokio::fs::remove_dir_all(&staging).await;
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "Failed to write uploaded file"})),
)
.into_response();
}
Json(serde_json::json!({
"tmp_path": dest.to_string_lossy(),
"upload_id": upload_id.to_string()
}))
.into_response()
}
/// POST /api/upload-tarball
///
/// Accepts a gzip-compressed tar archive as a raw binary body (`Content-Type: application/gzip`).
/// Streams the body to a temp file, then extracts it with the vendored `tar` + `flate2` crates.
/// Returns `{ tmp_path, upload_id, compressed_bytes, original_bytes }` pointing at the extracted
/// project root. The two size fields power the "Original / Compressed project size" display in the
/// web UI.
///
/// `DefaultBodyLimit::disable()` is applied per-route so there is no hard size cap at the HTTP
/// layer; the only limit is the disk space on the server. The browser-side JS creates the archive
/// one file at a time using the native `CompressionStream('gzip')` API so browser RAM usage stays
/// bounded regardless of project size.
/// Guards against zip-bomb archives: errors once more than `remaining` bytes have been
/// decompressed. Wraps any `std::io::Read` source.
struct SizeLimitReader<R> {
inner: R,
remaining: u64,
}
impl<R: std::io::Read> std::io::Read for SizeLimitReader<R> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
if self.remaining == 0 {
return Err(std::io::Error::other("decompressed size limit exceeded"));
}
let n = self.inner.read(buf)?;
self.remaining = self.remaining.saturating_sub(n as u64);
Ok(n)
}
}
async fn upload_tarball_handler(
State(state): State<AppState>,
request: axum::extract::Request,
) -> Response {
if !state.server_mode {
return StatusCode::NOT_FOUND.into_response();
}
let upload_id = uuid::Uuid::new_v4().to_string();
let upload_base = upload_base_dir();
let tarball_path = upload_base.join(format!("{upload_id}.tar.gz"));
let staging = upload_staging_path(&upload_id);
let (max_compressed_bytes, max_decompressed_bytes) = parse_tarball_size_caps();
if let Err(e) = tokio::fs::create_dir_all(&upload_base).await {
tracing::error!(
event = "upload_io_error",
"failed to create upload base dir: {e}"
);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "Upload initialization failed"})),
)
.into_response();
}
// ── 1. Stream the request body to a temp file (bounded RAM) ──────────────
let compressed_bytes =
match stream_body_to_file(request.into_body(), &tarball_path, max_compressed_bytes).await {
Ok(n) => n,
Err(resp) => return resp,
};
// ── 2. Extract the tar.gz in a blocking thread; tarball_path removed inside ──
if let Err(resp) =
extract_tarball_to_staging(&tarball_path, &staging, max_decompressed_bytes).await
{
return resp;
}
// ── 3. Find the project root inside the staging dir ───────────────────────
// If the tar contained a single top-level directory (the common case when the
// browser uses `webkitRelativePath`), return that as the scan root so the path
// shown in the UI is clean (e.g. staging/<uuid>/myproject, not staging/<uuid>).
let scan_root = find_single_top_dir(&staging)
.await
.unwrap_or_else(|| staging.clone());
// Compute original (uncompressed) size of the extracted tree.
let original_bytes = tokio::task::spawn_blocking({
let p = scan_root.clone();
move || dir_size_bytes(&p)
})
.await
.unwrap_or(0);
Json(serde_json::json!({
"tmp_path": scan_root.to_string_lossy(),
"upload_id": upload_id,
"compressed_bytes": compressed_bytes,
"original_bytes": original_bytes,
}))
.into_response()
}
#[derive(Deserialize)]
struct LocateReportForm {
file_path: String,
#[serde(default)]
redirect_url: Option<String>,
#[serde(default)]
expected_run_id: Option<String>,
}
/// Render a view-reports error page and return it as a `Response`.
fn locate_report_error(message: impl Into<String>, csp_nonce: &str) -> Response {
let html = ErrorTemplate {
message: message.into(),
last_report_url: Some("/view-reports".to_string()),
last_report_label: Some("View Reports".to_string()),
run_id: None,
error_code: None,
csp_nonce: csp_nonce.to_owned(),
version: env!("CARGO_PKG_VERSION"),
}
.render()
.unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
Html(html).into_response()
}
/// Build a `RegistryEntry` from an `AnalysisRun` loaded from the given JSON path.
fn registry_entry_from_run(
run: &AnalysisRun,
json_path: PathBuf,
html_path: PathBuf,
) -> RegistryEntry {
let project_label = run.input_roots.first().map_or_else(
|| "Unknown Project".to_string(),
|r| sanitize_project_label(r),
);
RegistryEntry {
run_id: run.tool.run_id.clone(),
timestamp_utc: run.tool.timestamp_utc,
project_label,
input_roots: run.input_roots.clone(),
json_path: Some(json_path),
html_path: Some(html_path),
pdf_path: None,
summary: ScanSummarySnapshot {
files_analyzed: run.summary_totals.files_analyzed,
files_skipped: run.summary_totals.files_skipped,
total_physical_lines: run.summary_totals.total_physical_lines,
code_lines: run.summary_totals.code_lines,
comment_lines: run.summary_totals.comment_lines,
blank_lines: run.summary_totals.blank_lines,
functions: run.summary_totals.functions,
classes: run.summary_totals.classes,
variables: run.summary_totals.variables,
imports: run.summary_totals.imports,
test_count: run.summary_totals.test_count,
coverage_lines_found: run.summary_totals.coverage_lines_found,
coverage_lines_hit: run.summary_totals.coverage_lines_hit,
coverage_functions_found: run.summary_totals.coverage_functions_found,
coverage_functions_hit: run.summary_totals.coverage_functions_hit,
coverage_branches_found: run.summary_totals.coverage_branches_found,
coverage_branches_hit: run.summary_totals.coverage_branches_hit,
},
csv_path: None,
xlsx_path: None,
git_branch: None,
git_commit: None,
git_author: None,
git_tags: None,
git_nearest_tag: None,
git_commit_date: None,
}
}
/// Register a webhook/poll-triggered scan in the live registry so it appears in /view-reports
/// immediately without requiring a server restart.
pub(crate) async fn register_artifacts_in_registry(
state: &AppState,
label: &str,
run: &AnalysisRun,
artifacts: &RunArtifacts,
) {
let Some(json_path) = artifacts.json_path.clone() else {
return;
};
let Some(html_path) = artifacts.html_path.clone() else {
return;
};
let mut entry = registry_entry_from_run(run, json_path, html_path);
entry.project_label = label.to_owned();
let mut reg = state.registry.lock().await;
reg.add_entry(entry);
let _ = reg.save(&state.registry_path);
}
fn is_html_report_file(p: &Path) -> bool {
p.is_file()
&& p.extension()
.and_then(|x| x.to_str())
.is_some_and(|x| x.eq_ignore_ascii_case("html"))
&& p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
}
fn find_html_report_in_dir(dir: &Path) -> Option<PathBuf> {
fs::read_dir(dir)
.ok()?
.flatten()
.map(|e| e.path())
.find(|p| is_html_report_file(p))
}
fn find_html_report_in_tree(dir: &Path) -> Option<PathBuf> {
if let Some(f) = find_html_report_in_dir(dir) {
return Some(f);
}
if let Ok(rd) = fs::read_dir(dir) {
for entry in rd.flatten() {
let sub = entry.path();
if sub.is_dir() {
if let Some(f) = find_html_report_in_dir(&sub) {
return Some(f);
}
}
}
}
None
}
/// Validate the locate-report form: accept either a folder (scan output dir) or an .html file,
/// resolve the canonical path, enforce server-mode root restriction, and extract parent dir.
///
/// Returns `Ok((html_path, parent))` or an error `Response` ready to return to the client.
#[allow(clippy::result_large_err)]
fn validate_locate_request(
state: &AppState,
file_path: &str,
csp_nonce: &str,
) -> Result<(PathBuf, PathBuf), Response> {
let raw = PathBuf::from(file_path);
// If the user pointed at a directory, find the HTML report inside it (or one level deep).
let html_path = if raw.is_dir() {
let found = find_html_report_in_tree(&raw);
match found {
Some(f) => strip_unc_prefix(fs::canonicalize(&f).unwrap_or(f)),
None => {
return Err(locate_report_error(
"No HTML report file found in the selected folder.\n\nMake sure you selected \
the folder that contains your scan output (result_*.html or report_*.html).",
csp_nonce,
));
}
}
} else {
let file_ext = raw
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
if file_ext != "html" {
return Err(locate_report_error(
"Please select the scan output folder, or an .html report file directly.",
csp_nonce,
));
}
match fs::canonicalize(&raw) {
Ok(p) => strip_unc_prefix(p),
Err(_) => {
return Err(locate_report_error(
"Report file not found or path is invalid.",
csp_nonce,
));
}
}
};
if state.server_mode {
let output_root = resolve_output_root(None);
let canonical_root = fs::canonicalize(&output_root).unwrap_or(output_root);
if !html_path.starts_with(&canonical_root) {
return Err(locate_report_error(
"Report file must be within the configured output directory.",
csp_nonce,
));
}
}
let parent = match html_path.parent() {
Some(p) => p.to_path_buf(),
None => {
return Err(locate_report_error(
"Report file has no parent directory.",
csp_nonce,
));
}
};
Ok((html_path, parent))
}
/// JSON-or-HTML error for `locate_report_handler` error paths.
fn locate_handler_err(want_json: bool, msg: String, csp_nonce: &str) -> Response {
if want_json {
(
StatusCode::UNPROCESSABLE_ENTITY,
axum::Json(serde_json::json!({"ok": false, "message": msg})),
)
.into_response()
} else {
locate_report_error(msg, csp_nonce)
}
}
/// JSON-or-redirect success for locate/relocate handler success paths.
fn redirect_or_json_ok(want_json: bool, redirect: &str) -> Response {
if want_json {
axum::Json(serde_json::json!({"ok": true, "redirect": redirect})).into_response()
} else {
axum::response::Redirect::to(redirect).into_response()
}
}
/// Scan `json_candidates` for a run whose `run_id` matches `expected` (or return the
/// first parseable run when `expected` is empty). Returns `(path, run_id)`.
fn find_json_run_by_id(candidates: &[PathBuf], expected: &str) -> Option<(PathBuf, String)> {
for jpath in candidates {
if let Ok(run) = read_json(jpath) {
if expected.is_empty() || run.tool.run_id == expected {
return Some((jpath.clone(), run.tool.run_id));
}
}
}
None
}
fn resolve_scan_root(html_path: &Path, parent: &Path) -> PathBuf {
html_path
.parent()
.and_then(|p| p.parent())
.map_or_else(|| parent.to_path_buf(), std::path::Path::to_path_buf)
}
fn gather_json_candidates(scan_root: &Path, parent: &Path) -> Vec<PathBuf> {
let mut hits = collect_result_json_candidates(scan_root);
if hits.is_empty() {
hits = collect_result_json_candidates(parent);
}
hits.sort();
hits
}
#[allow(clippy::too_many_lines)]
async fn locate_report_handler(
State(state): State<AppState>,
axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
headers: axum::http::HeaderMap,
Form(form): Form<LocateReportForm>,
) -> impl IntoResponse {
let want_json = headers
.get(axum::http::header::ACCEPT)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.contains("application/json"));
let (html_path, parent) = match validate_locate_request(&state, &form.file_path, &csp_nonce) {
Ok(v) => v,
Err(resp) => {
if want_json {
return locate_handler_err(
true,
"No HTML report file found in the selected folder. \
Make sure you selected the folder that contains your \
scan output (look for the folder with html/, json/, pdf/ subdirs)."
.to_string(),
&csp_nonce,
);
}
return resp;
}
};
// Search for result_*.json in the HTML's parent and also its grandparent (handles
// layouts where HTML is in a named subdir like html/ alongside json/, pdf/, etc.).
let scan_root_owned = resolve_scan_root(&html_path, &parent);
let scan_root: &Path = &scan_root_owned;
let json_candidates = gather_json_candidates(scan_root, &parent);
// If the expected_run_id was provided, find a JSON that matches it exactly.
let expected_run_id = form
.expected_run_id
.as_deref()
.unwrap_or("")
.trim()
.to_string();
let matched_json = find_json_run_by_id(&json_candidates, &expected_run_id);
// If we have candidates but none matched the expected run_id, surface a clear error.
if matched_json.is_none() && !json_candidates.is_empty() && !expected_run_id.is_empty() {
let actual = json_candidates
.iter()
.find_map(|p| read_json(p).ok().map(|r| r.tool.run_id))
.unwrap_or_else(|| "unknown".to_string());
return locate_handler_err(
want_json,
format!(
"This folder contains a different scan.\n\n\
Expected run ID : {expected_run_id}\n\
Found run ID : {actual}\n\n\
Please select the folder that contains the correct scan output."
),
&csp_nonce,
);
}
let safe_redirect = form
.redirect_url
.as_deref()
.filter(|u| u.starts_with('/') && !u.starts_with("//"))
.unwrap_or("/view-reports?linked=1")
.to_string();
let mut reg = state.registry.lock().await;
if let Some((json_path, run_id)) = matched_json {
// Match by run_id in the registry (works even after files are moved).
if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
entry.html_path = Some(html_path);
entry.json_path = Some(json_path);
let _ = reg.save(&state.registry_path);
drop(reg);
// Evict the stale in-memory cache so artifact_handler reads fresh from registry.
state.artifacts.lock().await.remove(&run_id);
return redirect_or_json_ok(want_json, &safe_redirect);
}
// No existing entry — build one from the JSON.
match read_json(&json_path) {
Ok(run) => {
let entry = registry_entry_from_run(&run, json_path, html_path);
reg.add_entry(entry);
let _ = reg.save(&state.registry_path);
drop(reg);
state.artifacts.lock().await.remove(&run_id);
return redirect_or_json_ok(want_json, &safe_redirect);
}
Err(e) => {
drop(reg);
return locate_handler_err(
want_json,
format!(
"Found the scan folder but could not parse the result JSON.\n\n\
The file may have been saved by an older version of OxideSLOC. \
Re-running the analysis will create a fresh, compatible record.\n\n\
Error: {e}"
),
&csp_nonce,
);
}
}
}
// No JSON found — if expected_run_id matches an existing registry entry, just update html_path.
if let Some(entry) = reg
.entries
.iter_mut()
.find(|e| !expected_run_id.is_empty() && e.run_id == expected_run_id)
{
entry.html_path = Some(html_path.clone());
let _ = reg.save(&state.registry_path);
drop(reg);
state.artifacts.lock().await.remove(&expected_run_id);
return redirect_or_json_ok(want_json, &safe_redirect);
}
drop(reg);
let hint = if state.server_mode {
String::new()
} else {
format!(
"\n\nSearched folder : {}\nHTML found : {}",
scan_root.display(),
html_path.display()
)
};
locate_handler_err(
want_json,
format!(
"Could not link this report.\n\n\
No result_*.json was found in the selected folder. \
Make sure you selected the top-level scan output folder \
(the one that contains html/, json/, pdf/ subfolders).{hint}"
),
&csp_nonce,
)
}
/// Returns the first `result*.json` file found directly inside `dir`, or `None`.
fn find_result_json_in_dir(dir: &Path) -> Option<PathBuf> {
fs::read_dir(dir)
.ok()?
.flatten()
.map(|e| e.path())
.find(|p| {
p.is_file()
&& p.file_stem()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("result"))
&& p.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("json"))
})
}
#[derive(Deserialize)]
struct LocateReportsDirForm {
folder_path: String,
}
#[allow(clippy::too_many_lines)] // report discovery handler with complex search and rendering logic
async fn locate_reports_dir_handler(
State(state): State<AppState>,
Form(form): Form<LocateReportsDirForm>,
) -> impl IntoResponse {
if state.server_mode {
return StatusCode::NOT_FOUND.into_response();
}
let folder = match fs::canonicalize(PathBuf::from(&form.folder_path)) {
Ok(p) => strip_unc_prefix(p),
Err(_) => {
return axum::response::Redirect::to(
"/view-reports?error=Folder+not+found+or+path+is+invalid.",
)
.into_response();
}
};
if !folder.is_dir() {
return axum::response::Redirect::to(
"/view-reports?error=Selected+path+is+not+a+directory.",
)
.into_response();
}
let candidates = collect_result_json_candidates(&folder);
if candidates.is_empty() {
return axum::response::Redirect::to(
"/view-reports?error=No+result+JSON+files+found+in+the+selected+folder+or+its+subdirectories.",
)
.into_response();
}
let mut linked_count: usize = 0;
let mut reg = state.registry.lock().await;
for json_path in candidates {
let Some(parent) = json_path.parent().map(PathBuf::from) else {
continue;
};
if is_dir_already_registered(®, &parent) {
continue;
}
let Some(entry) = build_registry_entry_from_json(json_path) else {
continue;
};
reg.add_entry(entry);
linked_count += 1;
}
let _ = reg.save(&state.registry_path);
drop(reg);
if linked_count == 0 {
return axum::response::Redirect::to(
"/view-reports?error=No+new+reports+were+loaded.+The+folder+may+already+be+indexed+or+files+could+not+be+parsed.",
)
.into_response();
}
axum::response::Redirect::to(&format!("/view-reports?linked={linked_count}")).into_response()
}
#[derive(Deserialize)]
struct RelocateScanForm {
run_id: String,
folder_path: String,
redirect_url: String,
}
/// JSON-or-HTML error for `relocate_scan_handler` folder-level errors.
/// HTML variant renders the relocate template; JSON returns `{"ok": false, "message": msg}`.
fn relocate_folder_err(
want_json: bool,
status: StatusCode,
msg: &str,
run_id: &str,
folder_hint: &str,
redirect_url: &str,
csp_nonce: &str,
) -> Response {
if want_json {
(
status,
axum::Json(serde_json::json!({"ok": false, "message": msg})),
)
.into_response()
} else {
missing_scan_relocate_response(msg, run_id, folder_hint, redirect_url, false, csp_nonce)
}
}
#[allow(clippy::too_many_lines)]
async fn relocate_scan_handler(
State(state): State<AppState>,
axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
headers: axum::http::HeaderMap,
Form(form): Form<RelocateScanForm>,
) -> impl IntoResponse {
let want_json = headers
.get(axum::http::header::ACCEPT)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.contains("application/json"));
if state.server_mode {
return StatusCode::NOT_FOUND.into_response();
}
let run_id = form.run_id.trim().to_string();
let redirect_url = form.redirect_url.trim().to_string();
let run_exists = {
let reg = state.registry.lock().await;
reg.find_by_run_id(&run_id).is_some()
};
if !run_exists {
if want_json {
return (
StatusCode::NOT_FOUND,
axum::Json(serde_json::json!({
"ok": false,
"message": format!("Run ID '{run_id}' not found in registry.")
})),
)
.into_response();
}
let html = ErrorTemplate {
message: format!("Run ID '{run_id}' not found in registry."),
last_report_url: Some("/compare-scans".to_string()),
last_report_label: Some("Compare Scans".to_string()),
run_id: Some(run_id.clone()),
error_code: Some(404),
csp_nonce: csp_nonce.clone(),
version: env!("CARGO_PKG_VERSION"),
}
.render()
.unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
return Html(html).into_response();
}
let folder = match fs::canonicalize(PathBuf::from(form.folder_path.trim())) {
Ok(p) => strip_unc_prefix(p),
Err(_) => {
return relocate_folder_err(
want_json,
StatusCode::UNPROCESSABLE_ENTITY,
"Folder not found or path is invalid.",
&run_id,
form.folder_path.trim(),
&redirect_url,
&csp_nonce,
);
}
};
if !folder.is_dir() {
return relocate_folder_err(
want_json,
StatusCode::UNPROCESSABLE_ENTITY,
"Selected path is not a directory.",
&run_id,
&folder.display().to_string(),
&redirect_url,
&csp_nonce,
);
}
let json_candidates = find_result_files_by_ext(&folder, "json");
if json_candidates.is_empty() {
let msg = format!(
"No result JSON files found in the selected folder.\nSearched: {}",
folder.display()
);
return relocate_folder_err(
want_json,
StatusCode::UNPROCESSABLE_ENTITY,
&msg,
&run_id,
&folder.display().to_string(),
&redirect_url,
&csp_nonce,
);
}
let Some(json_path) = find_matching_run_json(&json_candidates, &run_id) else {
let msg = format!(
"No matching scan found in the selected folder.\n\
The JSON files present do not contain run ID: {run_id}\n\
Searched: {}",
folder.display()
);
return relocate_folder_err(
want_json,
StatusCode::UNPROCESSABLE_ENTITY,
&msg,
&run_id,
&folder.display().to_string(),
&redirect_url,
&csp_nonce,
);
};
let html_path = find_result_files_by_ext(&folder, "html").into_iter().next();
let pdf_path = find_result_files_by_ext(&folder, "pdf").into_iter().next();
update_run_file_paths(&state, &run_id, json_path, html_path, pdf_path).await;
let safe_redirect = if redirect_url.starts_with('/') && !redirect_url.starts_with("//") {
redirect_url
} else {
"/compare-scans".to_string()
};
redirect_or_json_ok(want_json, &safe_redirect)
}
fn find_result_files_by_ext(folder: &std::path::Path, ext: &str) -> Vec<PathBuf> {
let mut out = Vec::new();
collect_scan_files_by_ext(folder, ext, &mut out);
if let Ok(rd) = fs::read_dir(folder) {
for entry in rd.flatten() {
let sub = entry.path();
if sub.is_dir() {
collect_scan_files_by_ext(&sub, ext, &mut out);
}
}
}
out
}
fn collect_scan_files_by_ext(dir: &std::path::Path, ext: &str, out: &mut Vec<PathBuf>) {
let Ok(rd) = fs::read_dir(dir) else { return };
for entry in rd.flatten() {
let p = entry.path();
if p.is_file()
&& p.file_stem()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
&& p.extension().is_some_and(|e| e.eq_ignore_ascii_case(ext))
{
out.push(p);
}
}
}
fn find_matching_run_json(candidates: &[PathBuf], run_id: &str) -> Option<PathBuf> {
candidates
.iter()
.find(|c| read_json(c).ok().is_some_and(|r| r.tool.run_id == run_id))
.cloned()
}
async fn update_run_file_paths(
state: &AppState,
run_id: &str,
json_path: PathBuf,
html_path: Option<PathBuf>,
pdf_path: Option<PathBuf>,
) {
let mut reg = state.registry.lock().await;
if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
entry.json_path = Some(json_path);
if let Some(hp) = html_path {
entry.html_path = Some(hp);
}
if let Some(pp) = pdf_path {
entry.pdf_path = Some(pp);
}
}
let _ = reg.save(&state.registry_path);
}
fn missing_scan_relocate_response(
message: &str,
run_id: &str,
folder_hint: &str,
redirect_url: &str,
server_mode: bool,
csp_nonce: &str,
) -> axum::response::Response {
let html = RelocateScanTemplate {
message: message.to_string(),
run_id: run_id.to_string(),
folder_hint: folder_hint.to_string(),
redirect_url: redirect_url.to_string(),
server_mode,
csp_nonce: csp_nonce.to_owned(),
version: env!("CARGO_PKG_VERSION"),
}
.render()
.unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
(StatusCode::NOT_FOUND, Html(html)).into_response()
}
// ── Watched-directory helpers ─────────────────────────────────────────────────
/// Collect `result*.json` candidates from `folder` and one level of subdirectories.
fn collect_result_json_candidates(folder: &std::path::Path) -> Vec<PathBuf> {
let mut candidates = Vec::new();
if let Some(j) = find_result_json_in_dir(folder) {
candidates.push(j);
}
if let Ok(dir_entries) = fs::read_dir(folder) {
for entry in dir_entries.flatten() {
let sub = entry.path();
if sub.is_dir() {
if let Some(j) = find_result_json_in_dir(&sub) {
candidates.push(j);
}
}
}
}
candidates
}
fn is_dir_already_registered(reg: &ScanRegistry, parent: &std::path::Path) -> bool {
reg.entries.iter().any(|e| {
let dir_match = e
.json_path
.as_ref()
.and_then(|p| p.parent())
.is_some_and(|p| p == parent)
|| e.html_path
.as_ref()
.and_then(|p| p.parent())
.is_some_and(|p| p == parent);
dir_match
&& (e.json_path.as_ref().is_some_and(|p| p.exists())
|| e.html_path.as_ref().is_some_and(|p| p.exists()))
})
}
fn build_registry_entry_from_json(json_path: PathBuf) -> Option<RegistryEntry> {
let parent = json_path.parent()?.to_path_buf();
let html_path = fs::read_dir(&parent).ok().and_then(|rd| {
rd.flatten()
.map(|e| e.path())
.find(|p| p.extension().and_then(|e| e.to_str()) == Some("html"))
});
let run = read_json(&json_path).ok()?;
let project_label = run.input_roots.first().map_or_else(
|| "Unknown Project".to_string(),
|r| sanitize_project_label(r),
);
Some(RegistryEntry {
run_id: run.tool.run_id.clone(),
timestamp_utc: run.tool.timestamp_utc,
project_label,
input_roots: run.input_roots.clone(),
json_path: Some(json_path),
html_path,
pdf_path: None,
csv_path: None,
xlsx_path: None,
summary: ScanSummarySnapshot {
files_analyzed: run.summary_totals.files_analyzed,
files_skipped: run.summary_totals.files_skipped,
total_physical_lines: run.summary_totals.total_physical_lines,
code_lines: run.summary_totals.code_lines,
comment_lines: run.summary_totals.comment_lines,
blank_lines: run.summary_totals.blank_lines,
functions: run.summary_totals.functions,
classes: run.summary_totals.classes,
variables: run.summary_totals.variables,
imports: run.summary_totals.imports,
test_count: run.summary_totals.test_count,
coverage_lines_found: run.summary_totals.coverage_lines_found,
coverage_lines_hit: run.summary_totals.coverage_lines_hit,
coverage_functions_found: run.summary_totals.coverage_functions_found,
coverage_functions_hit: run.summary_totals.coverage_functions_hit,
coverage_branches_found: run.summary_totals.coverage_branches_found,
coverage_branches_hit: run.summary_totals.coverage_branches_hit,
},
git_branch: run.git_branch.clone(),
git_commit: run.git_commit_short.clone(),
git_author: run.git_commit_author.clone(),
git_tags: run.git_tags.clone(),
git_nearest_tag: run.git_nearest_tag.clone(),
git_commit_date: run.git_commit_date,
})
}
/// Scan `folder` (and one level of subdirs) for `result*.json` files and add any new ones to `reg`.
/// Returns the number of newly linked entries.
fn scan_folder_into_registry(folder: &std::path::Path, reg: &mut ScanRegistry) -> usize {
let mut linked = 0usize;
for json_path in collect_result_json_candidates(folder) {
let Some(parent) = json_path.parent().map(PathBuf::from) else {
continue;
};
if is_dir_already_registered(reg, &parent) {
continue;
}
let Some(entry) = build_registry_entry_from_json(json_path) else {
continue;
};
reg.add_entry(entry);
linked += 1;
}
linked
}
/// Scan all watched directories (plus the default output root) into `reg`.
async fn auto_scan_watched_dirs(state: &AppState) {
let dirs: Vec<PathBuf> = {
let wd = state.watched_dirs.lock().await;
wd.dirs.clone()
};
if dirs.is_empty() {
return;
}
let mut reg = state.registry.lock().await;
let mut total = 0usize;
for dir in &dirs {
if dir.is_dir() {
total += scan_folder_into_registry(dir, &mut reg);
}
}
if total > 0 {
let _ = reg.save(&state.registry_path);
}
}
// ── Watched-dir route forms ───────────────────────────────────────────────────
#[derive(Deserialize)]
struct WatchedDirForm {
folder_path: String,
#[serde(default = "default_redirect")]
redirect_to: String,
}
fn default_redirect() -> String {
"/view-reports".to_string()
}
#[derive(Deserialize)]
struct WatchedDirRefreshForm {
#[serde(default = "default_redirect")]
redirect_to: String,
}
// ── Watched-dir helpers ───────────────────────────────────────────────────────
/// Reject any redirect target that is not a relative path to prevent open-redirect attacks.
fn safe_redirect(dest: &str) -> &str {
if dest.starts_with('/') {
dest
} else {
"/"
}
}
// ── Watched-dir handlers ──────────────────────────────────────────────────────
async fn add_watched_dir_handler(
State(state): State<AppState>,
Form(form): Form<WatchedDirForm>,
) -> impl IntoResponse {
if state.server_mode {
return StatusCode::NOT_FOUND.into_response();
}
let folder = if let Ok(p) = fs::canonicalize(PathBuf::from(&form.folder_path)) {
strip_unc_prefix(p)
} else {
let dest = format!(
"{}?error=Folder+not+found+or+path+is+invalid.",
safe_redirect(&form.redirect_to)
);
return axum::response::Redirect::to(&dest).into_response();
};
if !folder.is_dir() {
let dest = format!(
"{}?error=Selected+path+is+not+a+directory.",
safe_redirect(&form.redirect_to)
);
return axum::response::Redirect::to(&dest).into_response();
}
// Persist the watched directory.
{
let mut wd = state.watched_dirs.lock().await;
wd.add(folder.clone());
let _ = wd.save(&state.watched_dirs_path);
}
// Immediately scan the folder and add any new reports.
let linked = {
let mut reg = state.registry.lock().await;
let n = scan_folder_into_registry(&folder, &mut reg);
if n > 0 {
let _ = reg.save(&state.registry_path);
}
n
};
let dest = if linked > 0 {
format!("{}?linked={linked}", safe_redirect(&form.redirect_to))
} else {
format!(
"{}?error=Folder+added+to+watch+list+but+no+new+reports+were+found.",
safe_redirect(&form.redirect_to)
)
};
axum::response::Redirect::to(&dest).into_response()
}
async fn remove_watched_dir_handler(
State(state): State<AppState>,
Form(form): Form<WatchedDirForm>,
) -> impl IntoResponse {
if state.server_mode {
return StatusCode::NOT_FOUND.into_response();
}
let folder = PathBuf::from(&form.folder_path);
{
let mut wd = state.watched_dirs.lock().await;
wd.remove(&folder);
let _ = wd.save(&state.watched_dirs_path);
}
axum::response::Redirect::to(safe_redirect(&form.redirect_to)).into_response()
}
async fn refresh_watched_dirs_handler(
State(state): State<AppState>,
Form(form): Form<WatchedDirRefreshForm>,
) -> impl IntoResponse {
if state.server_mode {
return StatusCode::NOT_FOUND.into_response();
}
let dirs: Vec<PathBuf> = {
let wd = state.watched_dirs.lock().await;
wd.dirs.clone()
};
let mut total = 0usize;
{
let mut reg = state.registry.lock().await;
for dir in &dirs {
if dir.is_dir() {
total += scan_folder_into_registry(dir, &mut reg);
}
}
if total > 0 {
let _ = reg.save(&state.registry_path);
}
}
let dest = if total > 0 {
format!("{}?linked={total}", safe_redirect(&form.redirect_to))
} else {
safe_redirect(&form.redirect_to).to_owned()
};
axum::response::Redirect::to(&dest).into_response()
}
#[derive(Debug, Deserialize)]
struct OpenPathQuery {
path: Option<String>,
}
fn find_existing_ancestor(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
let mut ancestor = std::path::Path::new(raw);
loop {
match ancestor.parent() {
Some(p) => {
ancestor = p;
if ancestor.is_dir() {
break;
}
}
None => return Err((StatusCode::BAD_REQUEST, "no existing ancestor found")),
}
}
Ok(ancestor.to_path_buf())
}
async fn resolve_open_target(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
match tokio::fs::canonicalize(raw).await {
Ok(canonical) if canonical.is_file() => canonical
.parent()
.map_or(Err((StatusCode::BAD_REQUEST, "path has no parent")), |p| {
Ok(p.to_path_buf())
}),
Ok(canonical) if canonical.is_dir() => Ok(canonical),
Ok(_) => Err((StatusCode::BAD_REQUEST, "path is not a file or directory")),
Err(_) => find_existing_ancestor(raw),
}
}
async fn open_path_handler(
State(state): State<AppState>,
Query(query): Query<OpenPathQuery>,
) -> impl IntoResponse {
if state.server_mode {
return Json(serde_json::json!({
"server_mode_disabled": true,
"message": "Opening a path in the file manager is only available in local desktop mode."
}))
.into_response();
}
// Skip the OS file-manager call in headless / CI environments.
if std::env::var("SLOC_HEADLESS").is_ok() {
return Json(serde_json::json!({ "opened": false, "headless": true })).into_response();
}
let raw = match query.path.as_deref() {
Some(p) if !p.is_empty() => p,
_ => return (StatusCode::BAD_REQUEST, "missing path").into_response(),
};
// Resolve the target directory. If the path doesn't exist yet (e.g. the output
// dir hasn't been created by a scan), walk up to the nearest existing ancestor
// so the file explorer still opens somewhere useful.
let target = match resolve_open_target(raw).await {
Ok(p) => p,
Err((code, msg)) => return (code, msg).into_response(),
};
#[cfg(target_os = "windows")]
win_dialog_focus::open_folder_foreground(target);
#[cfg(target_os = "macos")]
let _ = std::process::Command::new("open")
.arg(&target)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
#[cfg(target_os = "linux")]
{
let folder_name = target
.file_name()
.and_then(|n| n.to_str())
.map(str::to_owned);
let _ = std::process::Command::new("xdg-open")
.arg(&target)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
// Best-effort: raise the file manager window once it appears.
// wmctrl is common on GNOME/KDE desktops but not guaranteed to be
// installed; failures are silently discarded.
if let Some(name) = folder_name {
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(800));
let _ = std::process::Command::new("wmctrl")
.args(["-a", &name])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
});
}
}
Json(serde_json::json!({"ok": true})).into_response()
}
async fn image_handler(AxumPath((folder, file)): AxumPath<(String, String)>) -> impl IntoResponse {
let (content_type, bytes): (&'static str, &'static [u8]) =
match (folder.as_str(), file.as_str()) {
("logo", "logo-text.png") => ("image/png", IMG_LOGO_TEXT),
("logo", "small-logo.png") => ("image/png", IMG_LOGO_SMALL),
("icons", "c.png") => ("image/png", IMG_ICON_C),
("icons", "cpp.png") => ("image/png", IMG_ICON_CPP),
("icons", "c-sharp.png") => ("image/png", IMG_ICON_CSHARP),
("icons", "python.png") => ("image/png", IMG_ICON_PYTHON),
("icons", "shell.png") => ("image/png", IMG_ICON_SHELL),
("icons", "powershell.png") => ("image/png", IMG_ICON_POWERSHELL),
("icons", "java-script.png") => ("image/png", IMG_ICON_JAVASCRIPT),
("icons", "html-5.png") => ("image/png", IMG_ICON_HTML),
("icons", "java.png") => ("image/png", IMG_ICON_JAVA),
("icons", "visual-basic.png") => ("image/png", IMG_ICON_VB),
("icons", "asm.png") => ("image/png", IMG_ICON_ASSEMBLY),
("icons", "go.png") => ("image/png", IMG_ICON_GO),
("icons", "r.png") => ("image/png", IMG_ICON_R),
("icons", "xml.png") => ("image/png", IMG_ICON_XML),
("icons", "groovy.png") => ("image/png", IMG_ICON_GROOVY),
("icons", "docker.png") => ("image/png", IMG_ICON_DOCKERFILE),
("icons", "makefile.svg") => ("image/svg+xml", IMG_ICON_MAKEFILE),
("icons", "perl.svg") => ("image/svg+xml", IMG_ICON_PERL),
_ => return StatusCode::NOT_FOUND.into_response(),
};
([(header::CONTENT_TYPE, content_type)], bytes).into_response()
}
async fn preview_handler(
State(state): State<AppState>,
Query(query): Query<PreviewQuery>,
) -> impl IntoResponse {
let raw_path = query
.path
.unwrap_or_else(|| "tests/fixtures/basic".to_string());
let resolved = resolve_input_path(&raw_path);
// If the sample path was requested but doesn't exist on this server (e.g. a deployed
// binary whose working directory is not the project root), return a clear message
// instead of an opaque OS error from build_preview_html.
if state.server_mode && is_sample_path(&resolved) && !resolved.exists() {
return Html(
r#"<div class="preview-error">Sample directory not available on this server.
Enter a path to a project directory or upload files using Browse.</div>"#
.to_string(),
);
}
if state.server_mode {
let canonical = fs::canonicalize(&resolved).unwrap_or_else(|_| resolved.clone());
// Upload temp dirs and built-in sample/fixture paths are always safe to preview.
if !is_upload_tmp_path(&canonical) && !is_sample_path(&canonical) {
let config = &state.base_config;
if config.discovery.allowed_scan_roots.is_empty() {
return Html(
r#"<div class="preview-error">Preview rejected: no allowed_scan_roots configured.</div>"#.to_string()
);
}
let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
fs::canonicalize(root)
.ok()
.is_some_and(|r| canonical.starts_with(&r))
});
if !allowed {
return Html(
r#"<div class="preview-error">Preview rejected: path is not within an allowed scan directory.</div>"#.to_string()
);
}
}
}
let include_patterns = split_patterns(query.include_globs.as_deref());
let exclude_patterns = split_patterns(query.exclude_globs.as_deref());
match build_preview_html(&resolved, &include_patterns, &exclude_patterns) {
Ok(html) => Html(html),
Err(err) => Html(format!(
r#"<div class="preview-error">Preview failed: {}</div>"#,
escape_html(&err.to_string())
)),
}
}
#[derive(Debug, Deserialize, Default)]
struct SuggestCoverageQuery {
path: Option<String>,
}
#[derive(Serialize)]
struct SuggestCoverageResponse {
found: Option<String>,
tool: Option<&'static str>,
hint: Option<&'static str>,
}
async fn api_suggest_coverage(Query(query): Query<SuggestCoverageQuery>) -> impl IntoResponse {
const CANDIDATES: &[&str] = &[
// LCOV — cargo-llvm-cov, gcov, lcov
"coverage/lcov.info",
"lcov.info",
"target/llvm-cov/lcov.info",
"target/coverage/lcov.info",
"target/debug/coverage/lcov.info",
"coverage/coverage.lcov",
"build/coverage/lcov.info",
"reports/lcov.info",
// Cobertura XML — pytest-cov, Maven Cobertura plugin, PHP
"coverage.xml",
"coverage/coverage.xml",
"target/site/cobertura/coverage.xml",
"build/reports/coverage/coverage.xml",
// JaCoCo XML — Gradle, Maven JaCoCo plugin
"target/site/jacoco/jacoco.xml",
"build/reports/jacoco/test/jacocoTestReport.xml",
"build/reports/jacoco/jacocoTestReport.xml",
"build/jacoco/jacoco.xml",
];
let root = resolve_input_path(query.path.as_deref().unwrap_or(""));
let found = CANDIDATES
.iter()
.map(|rel| root.join(rel))
.find(|p| p.is_file())
.map(|p| display_path(&p));
let (tool, hint) = detect_coverage_tool(&root);
Json(SuggestCoverageResponse { found, tool, hint })
}
/// Inspect the project root for known build/package files and return the most likely coverage
/// tool name and the shell command needed to generate a coverage file.
fn detect_coverage_tool(root: &Path) -> (Option<&'static str>, Option<&'static str>) {
if root.join("Cargo.toml").is_file() {
return (
Some("cargo-llvm-cov"),
Some("cargo llvm-cov --lcov --output-path coverage/lcov.info"),
);
}
if root.join("build.gradle").is_file() || root.join("build.gradle.kts").is_file() {
return (Some("jacoco"), Some("./gradlew jacocoTestReport"));
}
if root.join("pom.xml").is_file() {
return (Some("jacoco"), Some("mvn test jacoco:report"));
}
if root.join("pyproject.toml").is_file() || root.join("setup.py").is_file() {
return (Some("pytest-cov"), Some("pytest --cov --cov-report=xml"));
}
(None, None)
}
/// Validate a scan path in server mode. Returns `Err(response)` if rejected.
#[allow(clippy::result_large_err)]
fn validate_server_scan_path(
config: &sloc_config::AppConfig,
resolved_path: &Path,
csp_nonce: &str,
) -> Result<(), Response> {
if config.discovery.allowed_scan_roots.is_empty() {
let template = ErrorTemplate {
message: "Scan path rejected: no allowed_scan_roots configured on this server. \
Set allowed_scan_roots in the server config to permit scanning."
.to_string(),
last_report_url: None,
last_report_label: None,
run_id: None,
error_code: Some(403),
csp_nonce: csp_nonce.to_owned(),
version: env!("CARGO_PKG_VERSION"),
};
return Err((
StatusCode::FORBIDDEN,
Html(
template
.render()
.unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
),
)
.into_response());
}
let canonical = fs::canonicalize(resolved_path).unwrap_or_else(|_| resolved_path.to_path_buf());
let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
fs::canonicalize(root)
.ok()
.is_some_and(|r| canonical.starts_with(&r))
});
if !allowed {
tracing::warn!(event = "path_rejected", path = %canonical.display(),
"Scan path not in allowed_scan_roots");
let template = ErrorTemplate {
message: "The requested path is not within an allowed scan directory.".to_string(),
last_report_url: None,
last_report_label: None,
run_id: None,
error_code: Some(403),
csp_nonce: csp_nonce.to_owned(),
version: env!("CARGO_PKG_VERSION"),
};
return Err((
StatusCode::FORBIDDEN,
Html(
template
.render()
.unwrap_or_else(|_| "<pre>Path not allowed.</pre>".to_string()),
),
)
.into_response());
}
Ok(())
}
/// Exclude the output directory from scanning so artifacts don't pollute counts.
fn apply_output_dir_exclusions(
config: &mut sloc_config::AppConfig,
project_path: &str,
raw_output_dir: &str,
) {
let project_root = resolve_input_path(project_path);
let raw_out = raw_output_dir.trim();
let resolved_out = if raw_out.is_empty() {
project_root.join("sloc")
} else if Path::new(raw_out).is_absolute() {
PathBuf::from(raw_out)
} else {
workspace_root().join(raw_out)
};
if let Ok(rel) = resolved_out.strip_prefix(&project_root) {
if let Some(first) = rel.iter().next().and_then(|c| c.to_str()) {
let dir = first.to_string();
if !config.discovery.excluded_directories.contains(&dir) {
config.discovery.excluded_directories.push(dir);
}
}
}
if !config
.discovery
.excluded_directories
.iter()
.any(|d| d == "sloc")
{
config
.discovery
.excluded_directories
.push("sloc".to_string());
}
}
/// Build a `ScanSummarySnapshot` from an `AnalysisRun`'s `summary_totals`.
const fn summary_snapshot_from_run(run: &AnalysisRun) -> ScanSummarySnapshot {
ScanSummarySnapshot {
files_analyzed: run.summary_totals.files_analyzed,
files_skipped: run.summary_totals.files_skipped,
total_physical_lines: run.summary_totals.total_physical_lines,
code_lines: run.summary_totals.code_lines,
comment_lines: run.summary_totals.comment_lines,
blank_lines: run.summary_totals.blank_lines,
functions: run.summary_totals.functions,
classes: run.summary_totals.classes,
variables: run.summary_totals.variables,
imports: run.summary_totals.imports,
test_count: run.summary_totals.test_count,
coverage_lines_found: run.summary_totals.coverage_lines_found,
coverage_lines_hit: run.summary_totals.coverage_lines_hit,
coverage_functions_found: run.summary_totals.coverage_functions_found,
coverage_functions_hit: run.summary_totals.coverage_functions_hit,
coverage_branches_found: run.summary_totals.coverage_branches_found,
coverage_branches_hit: run.summary_totals.coverage_branches_hit,
}
}
/// Build the `RegistryEntry` for the just-completed scan run.
pub(crate) fn build_run_registry_entry(
run: &AnalysisRun,
run_id: &str,
project_label: &str,
artifacts: &RunArtifacts,
) -> RegistryEntry {
RegistryEntry {
run_id: run_id.to_owned(),
timestamp_utc: run.tool.timestamp_utc,
project_label: project_label.to_owned(),
input_roots: run.input_roots.clone(),
json_path: artifacts.json_path.clone(),
html_path: artifacts.html_path.clone(),
pdf_path: artifacts.pdf_path.clone(),
csv_path: artifacts.csv_path.clone(),
xlsx_path: artifacts.xlsx_path.clone(),
summary: summary_snapshot_from_run(run),
git_branch: run.git_branch.clone(),
git_commit: run.git_commit_short.clone(),
git_author: run.git_commit_author.clone(),
git_tags: run.git_tags.clone(),
git_nearest_tag: run.git_nearest_tag.clone(),
git_commit_date: run.git_commit_date.clone(),
}
}
/// Map `AnalyzeForm` fields onto `config`, covering all options visible in the web form.
fn apply_form_to_config(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
if let Some(policy) = form.mixed_line_policy {
config.analysis.mixed_line_policy = policy;
}
config.analysis.python_docstrings_as_comments = form.python_docstrings_as_comments.is_some();
config.analysis.generated_file_detection =
form.generated_file_detection.as_deref() != Some("disabled");
config.analysis.minified_file_detection =
form.minified_file_detection.as_deref() != Some("disabled");
config.analysis.vendor_directory_detection =
form.vendor_directory_detection.as_deref() != Some("disabled");
config.analysis.include_lockfiles = form.include_lockfiles.as_deref() == Some("enabled");
if let Some(binary_behavior) = form.binary_file_behavior {
config.analysis.binary_file_behavior = binary_behavior;
}
apply_report_opts(config, form);
config.discovery.include_globs = split_patterns(form.include_globs.as_deref());
config.discovery.exclude_globs = split_patterns(form.exclude_globs.as_deref());
config.discovery.submodule_breakdown = form.submodule_breakdown.as_deref() == Some("enabled");
if let Some(policy) = form.continuation_line_policy {
config.analysis.continuation_line_policy = policy;
}
if let Some(policy) = form.blank_in_block_comment_policy {
config.analysis.blank_in_block_comment_policy = policy;
}
config.analysis.count_compiler_directives =
form.count_compiler_directives.as_deref() != Some("disabled");
apply_style_threshold(config, form);
apply_coverage_path(config, form);
}
fn apply_report_opts(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
if let Some(report_title) = form.report_title.as_deref() {
let trimmed = report_title.trim();
if !trimmed.is_empty() {
config.reporting.report_title = trimmed.to_string();
}
}
if let Some(hf) = form.report_header_footer.as_deref() {
let trimmed = hf.trim();
config.reporting.report_header_footer = if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
};
}
}
fn apply_style_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
if let Some(threshold_str) = form.style_col_threshold.as_deref() {
if let Ok(t) = threshold_str.parse::<u16>() {
if t == 80 || t == 100 || t == 120 {
config.analysis.style_col_threshold = t;
}
}
}
if let Some(v) = form.style_analysis_enabled.as_deref() {
config.analysis.style_analysis_enabled = v != "disabled";
}
if let Some(v) = form.style_score_threshold.as_deref() {
if let Ok(t) = v.parse::<u8>() {
config.analysis.style_score_threshold = t.min(100);
}
}
if let Some(v) = form.style_lang_scope.as_deref() {
let scope = v.trim();
if scope == "c_family" || scope == "all" {
config.analysis.style_lang_scope = scope.to_string();
}
}
}
fn apply_coverage_path(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
if let Some(cov) = &form.coverage_file {
let trimmed = cov.trim();
if !trimmed.is_empty() {
config.analysis.coverage_file = Some(std::path::PathBuf::from(trimmed));
}
}
}
/// Fire-and-forget: generate the PDF in a background task if one is pending.
/// On failure, clears `pdf_path` in the artifacts map so the results page shows
/// an error instead of spinning indefinitely.
fn spawn_pdf_background(
pending_pdf: PendingPdf,
run_id: String,
artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
) {
if let Some((pdf_src, pdf_dst, cleanup_src)) = pending_pdf {
tokio::spawn(async move {
let result = tokio::task::spawn_blocking(move || {
let r = write_pdf_from_html(&pdf_src, &pdf_dst);
if cleanup_src {
let _ = fs::remove_file(&pdf_src);
}
r
})
.await;
let failed = match result {
Ok(Ok(())) => false,
Ok(Err(err)) => {
eprintln!("[oxide-sloc][pdf] background PDF failed: {err}");
true
}
Err(err) => {
eprintln!("[oxide-sloc][pdf] background PDF task panicked: {err}");
true
}
};
if failed {
let mut map = artifacts.lock().await;
if let Some(entry) = map.get_mut(&run_id) {
entry.pdf_path = None;
}
}
});
}
}
/// On-demand PDF generation using the pure-Rust `write_pdf_from_run` path (same as scan time).
/// Loads the stored JSON, regenerates the PDF, and clears `pdf_path` on failure so the
/// result page can show an error on the next visit instead of spinning indefinitely.
fn spawn_native_pdf_background(
json_path: PathBuf,
pdf_dest: PathBuf,
run_id: String,
artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
) {
tokio::spawn(async move {
let result = tokio::task::spawn_blocking(move || {
let run = sloc_core::read_json(&json_path)?;
write_pdf_from_run(&run, &pdf_dest)
})
.await;
let failed = match result {
Ok(Ok(())) => false,
Ok(Err(err)) => {
eprintln!("[oxide-sloc][pdf] on-demand PDF failed: {err}");
true
}
Err(err) => {
eprintln!("[oxide-sloc][pdf] on-demand PDF task panicked: {err}");
true
}
};
if failed {
let mut map = artifacts.lock().await;
if let Some(entry) = map.get_mut(&run_id) {
entry.pdf_path = None;
}
}
});
}
/// Sum the code lines added in this comparison (new + grown files).
fn sum_added_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
cmp.file_deltas
.iter()
.map(|f| match f.status {
FileChangeStatus::Added => f.current_code,
FileChangeStatus::Modified => f.code_delta.max(0),
_ => 0,
})
.sum()
}
/// Sum the code lines removed in this comparison (deleted + shrunk files).
fn sum_removed_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
cmp.file_deltas
.iter()
.map(|f| match f.status {
FileChangeStatus::Removed => f.baseline_code,
FileChangeStatus::Modified => (-f.code_delta).max(0),
_ => 0,
})
.sum()
}
/// Sum the code lines present in both scans without any change (Unchanged files).
fn sum_unmodified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
cmp.file_deltas
.iter()
.filter(|f| f.status == FileChangeStatus::Unchanged)
.map(|f| f.current_code)
.sum()
}
/// Build one `SubmoduleRow`, generating and persisting a sub-report HTML file when available.
fn build_submodule_row(
s: &sloc_core::SubmoduleSummary,
run: &AnalysisRun,
run_id: &str,
run_dir: &Path,
) -> SubmoduleRow {
let safe = sanitize_project_label(&s.name);
let artifact_key = format!("sub_{safe}");
let pdf_artifact_key = format!("sub_{safe}_pdf");
let html_url = if run.effective_configuration.discovery.submodule_breakdown {
let parent_path = run
.input_roots
.first()
.map_or("", std::string::String::as_str);
let sub_run = build_sub_run(run, s, parent_path);
let pdf_server_url = format!("/runs/{pdf_artifact_key}/{run_id}");
render_sub_report_html(&sub_run, Some(&pdf_server_url))
.ok()
.and_then(|sub_html| {
let sub_dir = run_dir.join("submodules");
let _ = fs::create_dir_all(&sub_dir);
let html_path = sub_dir.join(format!("{artifact_key}.html"));
if fs::write(&html_path, sub_html.as_bytes()).is_ok() {
// Pre-generate the sub-report PDF using the programmatic renderer
// so "View PDF" never needs to spawn Chrome for submodules.
let pdf_path = sub_dir.join(format!("{artifact_key}.pdf"));
let _ = write_pdf_from_run(&sub_run, &pdf_path);
Some(format!("/runs/{artifact_key}/{run_id}"))
} else {
None
}
})
} else {
None
};
SubmoduleRow {
name: s.name.clone(),
relative_path: s.relative_path.clone(),
files_analyzed: s.files_analyzed,
code_lines: s.code_lines,
comment_lines: s.comment_lines,
blank_lines: s.blank_lines,
total_physical_lines: s.total_physical_lines,
html_url,
}
}
// Immediately returns a wait page and runs the analysis in a background tokio task.
// The semaphore permit is moved into the spawned task so concurrency limiting is maintained.
#[allow(clippy::similar_names)]
#[allow(clippy::significant_drop_tightening)] // task is moved into spawn; drop(task) would not compile
#[allow(clippy::too_many_lines)]
async fn analyze_handler(
State(state): State<AppState>,
axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
Form(form): Form<AnalyzeForm>,
) -> impl IntoResponse {
let Ok(sem_permit) = Arc::clone(&state.analyze_semaphore).try_acquire_owned() else {
let template = ErrorTemplate {
message: format!(
"Server is busy — all {MAX_CONCURRENT_ANALYSES} analysis slots are in use. \
Please wait a moment and try again."
),
last_report_url: None,
last_report_label: None,
run_id: None,
error_code: Some(503),
csp_nonce: csp_nonce.clone(),
version: env!("CARGO_PKG_VERSION"),
};
return (
StatusCode::SERVICE_UNAVAILABLE,
Html(
template
.render()
.unwrap_or_else(|_| "<pre>Server busy.</pre>".to_string()),
),
)
.into_response();
};
let mut config = state.base_config.clone();
let git_repo = form.git_repo.clone().filter(|s| !s.is_empty());
let git_ref_name = form.git_ref.clone().filter(|s| !s.is_empty());
let is_git_mode = git_repo.is_some() && git_ref_name.is_some();
if !is_git_mode {
let resolved_path = resolve_input_path(&form.path);
if state.server_mode
&& !is_upload_tmp_path(&resolved_path)
&& !is_sample_path(&resolved_path)
{
if let Err(resp) = validate_server_scan_path(&config, &resolved_path, &csp_nonce) {
return resp;
}
}
config.discovery.root_paths = vec![resolved_path];
}
apply_form_to_config(&mut config, &form);
apply_output_dir_exclusions(
&mut config,
&form.path,
form.output_dir.as_deref().unwrap_or(""),
);
// Generate a wait_id now (before spawning) so the client can poll for status.
let wait_id = uuid::Uuid::new_v4().to_string();
let wait_id_json = serde_json::to_string(&wait_id).unwrap_or_else(|_| "\"\"".to_owned());
// Cancel token: set to true by the cancel endpoint to abort the running analysis.
let cancel_token = Arc::new(std::sync::atomic::AtomicBool::new(false));
let task_cancel = Arc::clone(&cancel_token);
// Phase tracker: updated by run_analysis_task at key checkpoints.
let phase = Arc::new(std::sync::Mutex::new("Starting".to_string()));
let task_phase = Arc::clone(&phase);
let files_done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let files_total = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let task_files_done = Arc::clone(&files_done);
let task_files_total = Arc::clone(&files_total);
// Register Running state before building the task struct so the semaphore permit
// (which has a significant Drop) isn't held across the async_runs lock acquisition.
{
let mut runs = state.async_runs.lock().await;
runs.insert(
wait_id.clone(),
AsyncRunState::Running {
started_at: std::time::Instant::now(),
cancel_token,
phase,
files_done,
files_total,
},
);
}
let task = AnalysisTask {
sem_permit,
state: state.clone(),
wait_id: wait_id.clone(),
config,
cancel: task_cancel,
phase: task_phase,
files_done: task_files_done,
files_total: task_files_total,
git_repo: form.git_repo.clone().filter(|s| !s.is_empty()),
git_ref: form.git_ref.clone().filter(|s| !s.is_empty()),
project_path: form.path.clone(),
// In server mode the client-supplied output_dir is ignored — artifacts are
// always written under the server's configured output root so remote users
// cannot direct writes to arbitrary filesystem paths.
output_dir: if state.server_mode {
None
} else {
form.output_dir.clone()
},
clones_dir: state.git_clones_dir.clone(),
cocomo_mode: form
.cocomo_mode
.clone()
.unwrap_or_else(|| "organic".to_string()),
complexity_alert: form
.complexity_alert
.as_deref()
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(0),
exclude_duplicates: form.exclude_duplicates.as_deref() == Some("enabled"),
};
tokio::spawn(run_analysis_task(task));
let template = ScanWaitTemplate {
version: env!("CARGO_PKG_VERSION"),
wait_id_json,
project_path: form.path.clone(),
csp_nonce,
};
let html = template
.render()
.unwrap_or_else(|err| format!("<pre>{err}</pre>"));
let mut response = Html(html).into_response();
if let Ok(name) = axum::http::HeaderName::from_bytes(b"x-wait-id") {
if let Ok(val) = axum::http::HeaderValue::from_str(&wait_id) {
response.headers_mut().insert(name, val);
}
}
response
}
struct AnalysisTask {
sem_permit: tokio::sync::OwnedSemaphorePermit,
state: AppState,
wait_id: String,
config: AppConfig,
cancel: Arc<std::sync::atomic::AtomicBool>,
phase: Arc<std::sync::Mutex<String>>,
files_done: Arc<std::sync::atomic::AtomicUsize>,
files_total: Arc<std::sync::atomic::AtomicUsize>,
git_repo: Option<String>,
git_ref: Option<String>,
project_path: String,
output_dir: Option<String>,
clones_dir: PathBuf,
cocomo_mode: String,
complexity_alert: u32,
exclude_duplicates: bool,
}
#[allow(clippy::too_many_lines)] // sequential async workflow; extracting more helpers adds no clarity
async fn run_analysis_task(task: AnalysisTask) {
let _permit = task.sem_permit;
let cancel_sb = Arc::clone(&task.cancel);
let (git_repo_sb, git_ref_sb) = (task.git_repo.clone(), task.git_ref.clone());
let clones_dir_sb = task.clones_dir;
// Save the upload staging path before config is moved into spawn_blocking.
let upload_staging_root = task
.config
.discovery
.root_paths
.first()
.filter(|p| is_upload_tmp_path(p))
.and_then(|p| p.parent().filter(|par| is_upload_tmp_path(par)))
.map(PathBuf::from);
let config_sb = task.config;
let progress_sb = sloc_core::ProgressCounters {
files_done: Arc::clone(&task.files_done),
files_total: Arc::clone(&task.files_total),
};
if let Ok(mut p) = task.phase.lock() {
*p = "Scanning files".to_string();
}
let analysis_result = tokio::task::spawn_blocking(move || {
run_analysis_blocking(
config_sb,
git_repo_sb,
git_ref_sb,
clones_dir_sb,
cancel_sb,
Some(progress_sb),
)
})
.await
.map_err(|err| anyhow::anyhow!(err.to_string()))
.and_then(|result| result);
if let Ok(mut p) = task.phase.lock() {
*p = "Writing reports".to_string();
}
// If cancelled while running, discard results and mark as cancelled.
if task.cancel.load(std::sync::atomic::Ordering::Relaxed) {
let mut runs = task.state.async_runs.lock().await;
// Only overwrite if still Running (don't clobber a Complete that snuck in).
if matches!(
runs.get(&task.wait_id),
Some(AsyncRunState::Running { .. } | AsyncRunState::Cancelled)
) {
runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
}
drop(runs);
return;
}
let run = match analysis_result {
Ok(v) => v,
Err(err) => {
// Distinguish user-cancelled from real failure.
if err.to_string().contains("analysis cancelled") {
let mut runs = task.state.async_runs.lock().await;
runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
drop(runs);
return;
}
eprintln!("[oxide-sloc][analyze] analysis failed: {err:#}");
let mut runs = task.state.async_runs.lock().await;
runs.insert(
task.wait_id.clone(),
AsyncRunState::Failed {
message: "Analysis failed. Check that the path exists and is readable."
.to_string(),
},
);
drop(runs);
return;
}
};
let run_id = run.tool.run_id.clone();
tracing::info!(event = "scan_complete", run_id = %run_id,
path = %task.project_path, files = run.summary_totals.files_analyzed,
"Analysis finished");
let prev_entry: Option<RegistryEntry> = {
let reg = task.state.registry.lock().await;
reg.entries_for_roots(&run.input_roots)
.into_iter()
.find(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
.cloned()
};
let scan_delta = prev_entry.as_ref().and_then(|prev| {
prev.json_path
.as_ref()
.and_then(|p| read_json(p).ok())
.map(|prev_run| compute_delta(&prev_run, &run))
});
let prev_scan_count: usize = {
let reg = task.state.registry.lock().await;
reg.entries_for_roots(&run.input_roots)
.iter()
.filter(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
.count()
};
// Build the HTML report now that delta is available, so the artifact
// embeds the full "Changes vs. Previous Scan" section for offline stakeholders.
let report_delta_ctx: Option<ReportDeltaContext> = scan_delta
.as_ref()
.zip(prev_entry.as_ref())
.map(|(cmp, prev)| ReportDeltaContext {
delta_code_added: sum_added_code_lines(cmp),
delta_code_removed: sum_removed_code_lines(cmp),
delta_unmodified_lines: sum_unmodified_code_lines(cmp),
delta_files_added: cmp.files_added,
delta_files_removed: cmp.files_removed,
delta_files_modified: cmp.files_modified,
delta_files_unchanged: cmp.files_unchanged,
prev_code_lines: prev.summary.code_lines,
prev_scan_count: prev_scan_count + 1,
prev_scan_label: fmt_la_time(prev.timestamp_utc),
prev_run_id: Some(prev.run_id.clone()),
current_run_id: Some(run_id.clone()),
});
let report_html = match render_html_with_delta(&run, report_delta_ctx.as_ref()) {
Ok(h) => h,
Err(err) => {
eprintln!("[oxide-sloc][analyze] HTML render failed: {err:#}");
let mut runs = task.state.async_runs.lock().await;
runs.insert(
task.wait_id.clone(),
AsyncRunState::Failed {
message: "Failed to render HTML report.".to_string(),
},
);
drop(runs);
return;
}
};
let output_root = resolve_output_root(task.output_dir.as_deref());
let project_label = derive_project_label(
task.git_repo.as_deref(),
task.git_ref.as_deref(),
&task.project_path,
);
let run_dir = output_root.join(format!("{project_label}_{run_id}"));
let file_stem = derive_file_stem(&project_label, run.git_commit_short.as_deref());
let result_context = RunResultContext {
prev_entry: prev_entry.clone(),
prev_scan_count,
project_path: task.project_path.clone(),
cocomo_mode: task.cocomo_mode.clone(),
complexity_alert: task.complexity_alert,
exclude_duplicates: task.exclude_duplicates,
};
let artifact_result = persist_run_artifacts(
&run,
&report_html,
&run_dir,
&run.effective_configuration.reporting.report_title,
&file_stem,
result_context,
);
let (artifacts, pending_pdf) = match artifact_result {
Ok(v) => v,
Err(err) => {
eprintln!("[oxide-sloc][analyze] artifact write failed: {err:#}");
let mut runs = task.state.async_runs.lock().await;
runs.insert(
task.wait_id.clone(),
AsyncRunState::Failed {
message: "Failed to save report artifacts. Check available disk space."
.to_string(),
},
);
drop(runs);
return;
}
};
{
let mut map = task.state.artifacts.lock().await;
map.insert(run_id.clone(), artifacts.clone());
}
{
let entry = build_run_registry_entry(&run, &run_id, &project_label, &artifacts);
let mut reg = task.state.registry.lock().await;
reg.add_entry(entry);
let _ = reg.save(&task.state.registry_path);
}
if let Some(ref cfg_path) = artifacts.scan_config_path {
save_scan_config_json(
cfg_path,
&run,
&task.project_path,
task.output_dir.as_deref(),
);
}
spawn_pdf_background(pending_pdf, run_id.clone(), task.state.artifacts.clone());
prom_runs_total().inc();
// Mark complete — client is now polling and will be redirected to /runs/result/{run_id}.
let mut runs = task.state.async_runs.lock().await;
runs.insert(
task.wait_id.clone(),
AsyncRunState::Complete {
run_id: run_id.clone(),
},
);
drop(runs);
// Remove the client-upload staging directory after a successful scan so
// that uploaded project files don't accumulate in the OS temp directory.
if let Some(staging) = upload_staging_root {
let _ = tokio::fs::remove_dir_all(staging).await;
}
let _ = scan_delta;
}
fn save_scan_config_json(
cfg_path: &std::path::Path,
run: &sloc_core::AnalysisRun,
project_path: &str,
output_dir: Option<&str>,
) {
let policy_str = serde_json::to_value(run.effective_configuration.analysis.mixed_line_policy)
.ok()
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_else(|| "code_only".to_string());
let behavior_str =
serde_json::to_value(run.effective_configuration.analysis.binary_file_behavior)
.ok()
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_else(|| "skip".to_string());
let scan_cfg = ScanConfig {
oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
path: project_path.to_string(),
include_globs: run
.effective_configuration
.discovery
.include_globs
.join("\n"),
exclude_globs: run
.effective_configuration
.discovery
.exclude_globs
.join("\n"),
submodule_breakdown: run.effective_configuration.discovery.submodule_breakdown,
mixed_line_policy: policy_str,
python_docstrings_as_comments: run
.effective_configuration
.analysis
.python_docstrings_as_comments,
generated_file_detection: run
.effective_configuration
.analysis
.generated_file_detection,
minified_file_detection: run.effective_configuration.analysis.minified_file_detection,
vendor_directory_detection: run
.effective_configuration
.analysis
.vendor_directory_detection,
include_lockfiles: run.effective_configuration.analysis.include_lockfiles,
binary_file_behavior: behavior_str,
output_dir: output_dir.unwrap_or("").to_string(),
report_title: run.effective_configuration.reporting.report_title.clone(),
};
if let Ok(json) = serde_json::to_string_pretty(&scan_cfg) {
let _ = std::fs::write(cfg_path, json);
}
}
#[allow(clippy::needless_pass_by_value)] // owned params required for spawn_blocking 'static bound
fn run_analysis_blocking(
mut config: AppConfig,
git_repo: Option<String>,
git_ref: Option<String>,
clones_dir: PathBuf,
cancel: Arc<std::sync::atomic::AtomicBool>,
progress: Option<sloc_core::ProgressCounters>,
) -> Result<sloc_core::AnalysisRun> {
if let (Some(repo), Some(refname)) = (git_repo, git_ref) {
let dest = git_clone_dest(&repo, &clones_dir);
sloc_git::clone_or_fetch(&repo, &dest)?;
let wt = clones_dir.join(format!("wt-{}", uuid::Uuid::new_v4().simple()));
sloc_git::create_worktree(&dest, &refname, &wt)?;
config.discovery.root_paths = vec![wt.clone()];
let run = analyze(&config, "serve", Some(&cancel), progress.as_ref());
let _ = sloc_git::destroy_worktree(&dest, &wt);
let mut run = run?;
if run.git_branch.is_none() {
run.git_branch = Some(refname);
}
return Ok(run);
}
analyze(&config, "serve", Some(&cancel), progress.as_ref())
}
fn derive_project_label(
git_repo: Option<&str>,
git_ref: Option<&str>,
fallback_path: &str,
) -> String {
match (
git_repo.filter(|s| !s.is_empty()),
git_ref.filter(|s| !s.is_empty()),
) {
(Some(repo), Some(refname)) => {
let repo_name = repo
.trim_end_matches('/')
.trim_end_matches(".git")
.rsplit('/')
.next()
.unwrap_or("repo");
sanitize_project_label(&format!("{repo_name}_{refname}"))
}
_ => sanitize_project_label(fallback_path),
}
}
fn derive_file_stem(project_label: &str, commit_short: Option<&str>) -> String {
let commit = commit_short.unwrap_or("").trim();
if commit.is_empty() {
project_label.to_string()
} else {
format!("{project_label}_{commit}")
}
}
// ── Async scan status + result handlers ──────────────────────────────────────
#[derive(Serialize)]
#[serde(tag = "state", rename_all = "snake_case")]
enum AsyncRunStatusResponse {
Running {
elapsed_secs: u64,
phase: String,
files_done: u64,
files_total: u64,
},
Complete {
run_id: String,
},
Failed {
message: String,
},
Cancelled,
}
async fn async_run_status_handler(
State(state): State<AppState>,
AxumPath(wait_id): AxumPath<String>,
) -> Response {
// wait_id comes from our own UUID generator; reject any structurally malformed value.
if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
return error::bad_request("invalid wait_id");
}
let run_state = {
let runs = state.async_runs.lock().await;
runs.get(&wait_id).cloned()
};
match run_state {
None => error::not_found("run not found"),
Some(AsyncRunState::Running {
started_at,
phase,
files_done,
files_total,
..
}) => {
// Treat runs older than 2 h as timed out (analysis should finish well under that).
if started_at.elapsed() > std::time::Duration::from_hours(2) {
let mut runs = state.async_runs.lock().await;
runs.insert(
wait_id,
AsyncRunState::Failed {
message: "Analysis timed out after 2 hours.".to_string(),
},
);
drop(runs);
return Json(AsyncRunStatusResponse::Failed {
message: "Analysis timed out after 2 hours.".to_string(),
})
.into_response();
}
let phase_str = phase.lock().map(|g| g.clone()).unwrap_or_default();
Json(AsyncRunStatusResponse::Running {
elapsed_secs: started_at.elapsed().as_secs(),
phase: phase_str,
files_done: files_done.load(std::sync::atomic::Ordering::Relaxed) as u64,
files_total: files_total.load(std::sync::atomic::Ordering::Relaxed) as u64,
})
.into_response()
}
Some(AsyncRunState::Complete { run_id }) => {
Json(AsyncRunStatusResponse::Complete { run_id }).into_response()
}
Some(AsyncRunState::Failed { message }) => {
Json(AsyncRunStatusResponse::Failed { message }).into_response()
}
Some(AsyncRunState::Cancelled) => Json(AsyncRunStatusResponse::Cancelled).into_response(),
}
}
async fn cancel_run_handler(
State(state): State<AppState>,
AxumPath(wait_id): AxumPath<String>,
) -> Response {
if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
return error::bad_request("invalid wait_id");
}
let mut runs = state.async_runs.lock().await;
let resp = match runs.get(&wait_id) {
Some(AsyncRunState::Running { cancel_token, .. }) => {
cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
runs.insert(wait_id, AsyncRunState::Cancelled);
StatusCode::OK.into_response()
}
Some(AsyncRunState::Cancelled) => StatusCode::OK.into_response(),
_ => error::not_found("run not found"),
};
drop(runs);
resp
}
async fn async_run_result_handler(
State(state): State<AppState>,
axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
AxumPath(run_id): AxumPath<String>,
) -> Response {
if run_id.len() > 128 || run_id.contains('/') || run_id.contains('\\') {
return StatusCode::BAD_REQUEST.into_response();
}
let artifacts = {
let map = state.artifacts.lock().await;
map.get(&run_id).cloned()
};
let artifacts = if let Some(a) = artifacts {
a
} else {
let reg = state.registry.lock().await;
if let Some(entry) = reg.find_by_run_id(&run_id) {
recover_artifacts_from_registry(entry)
} else {
let html = ErrorTemplate {
message: format!(
"Report not found. Run ID {} is not in the scan history.",
&run_id[..run_id.len().min(8)]
),
last_report_url: Some("/view-reports".to_string()),
last_report_label: Some("View Reports".to_string()),
run_id: Some(run_id.clone()),
error_code: Some(404),
csp_nonce: csp_nonce.clone(),
version: env!("CARGO_PKG_VERSION"),
}
.render()
.unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
return (StatusCode::NOT_FOUND, Html(html)).into_response();
}
};
let json_path = if let Some(p) = &artifacts.json_path {
p.clone()
} else {
let html = ErrorTemplate {
message: "JSON result was not saved for this run.".to_string(),
last_report_url: Some("/view-reports".to_string()),
last_report_label: Some("View Reports".to_string()),
run_id: Some(run_id.clone()),
error_code: Some(404),
csp_nonce: csp_nonce.clone(),
version: env!("CARGO_PKG_VERSION"),
}
.render()
.unwrap_or_else(|_| "<pre>No JSON.</pre>".to_string());
return (StatusCode::NOT_FOUND, Html(html)).into_response();
};
let Ok(run) = read_json(&json_path) else {
let folder_hint = json_path
.parent()
.map(|p| p.display().to_string())
.unwrap_or_default();
let redirect_url = format!("/runs/result/{run_id}");
return missing_scan_relocate_response(
&format!(
"Scan file could not be read:\n {}\n\nThe file may have been moved or \
deleted. Browse to the folder containing your scan output to reconnect it.",
json_path.display()
),
&run_id,
&folder_hint,
&redirect_url,
state.server_mode,
&csp_nonce,
);
};
let confluence_configured = {
let store = state.confluence.lock().await;
store.is_configured()
};
render_result_page(
&run,
&artifacts,
&run_id,
&csp_nonce,
confluence_configured,
state.server_mode,
)
}
#[allow(clippy::too_many_lines)]
#[allow(clippy::similar_names)] // abbreviated names (fa=files_analyzed, cl=code_lines, etc.) are intentional
#[allow(clippy::cast_precision_loss)] // COCOMO ratio: f64 precision on line counts is adequate
fn render_result_page(
run: &AnalysisRun,
artifacts: &RunArtifacts,
run_id: &str,
csp_nonce: &str,
confluence_configured: bool,
server_mode: bool,
) -> Response {
let ctx = &artifacts.result_context;
let prev_entry = &ctx.prev_entry;
let prev_scan_count = ctx.prev_scan_count;
let project_path = &ctx.project_path;
let scan_delta = prev_entry.as_ref().and_then(|prev| {
prev.json_path
.as_ref()
.and_then(|p| read_json(p).ok())
.map(|prev_run| compute_delta(&prev_run, run))
});
let files_analyzed = run.per_file_records.len() as u64;
let files_skipped = run.skipped_file_records.len() as u64;
let physical_lines = run
.totals_by_language
.iter()
.map(|r| r.total_physical_lines)
.sum::<u64>();
let code_lines = run
.totals_by_language
.iter()
.map(|r| r.code_lines)
.sum::<u64>();
let comment_lines = run
.totals_by_language
.iter()
.map(|r| r.comment_lines)
.sum::<u64>();
let blank_lines = run
.totals_by_language
.iter()
.map(|r| r.blank_lines)
.sum::<u64>();
let mixed_lines = run
.totals_by_language
.iter()
.map(|r| r.mixed_lines_separate)
.sum::<u64>();
let functions = run
.totals_by_language
.iter()
.map(|r| r.functions)
.sum::<u64>();
let classes = run
.totals_by_language
.iter()
.map(|r| r.classes)
.sum::<u64>();
let variables = run
.totals_by_language
.iter()
.map(|r| r.variables)
.sum::<u64>();
let imports = run
.totals_by_language
.iter()
.map(|r| r.imports)
.sum::<u64>();
let prev_sum = prev_entry.as_ref().map(|e| &e.summary);
let prev_fa = prev_sum.map(|s| s.files_analyzed);
let prev_fs = prev_sum.map(|s| s.files_skipped);
let prev_pl = prev_sum.map(|s| s.total_physical_lines);
let prev_cl = prev_sum.map(|s| s.code_lines);
let prev_cml = prev_sum.map(|s| s.comment_lines);
let prev_bl = prev_sum.map(|s| s.blank_lines);
let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "—".into(), |v| v.to_string());
let prev_fa_str = fmt_prev(prev_fa);
let prev_fs_str = fmt_prev(prev_fs);
let prev_pl_str = fmt_prev(prev_pl);
let prev_cl_str = fmt_prev(prev_cl);
let prev_cml_str = fmt_prev(prev_cml);
let prev_bl_str = fmt_prev(prev_bl);
let (delta_fa_str, delta_fa_class) = summary_delta(files_analyzed, prev_fa);
let (delta_fs_str, delta_fs_class) = summary_delta(files_skipped, prev_fs);
let (delta_pl_str, delta_pl_class) = summary_delta(physical_lines, prev_pl);
let (delta_cl_str, delta_cl_class) = summary_delta(code_lines, prev_cl);
let (delta_cml_str, delta_cml_class) = summary_delta(comment_lines, prev_cml);
let (delta_bl_str, delta_bl_class) = summary_delta(blank_lines, prev_bl);
let delta_fa_class = delta_fa_class.to_string();
let delta_fs_class = delta_fs_class.to_string();
let delta_pl_class = delta_pl_class.to_string();
let delta_cl_class = delta_cl_class.to_string();
let delta_cml_class = delta_cml_class.to_string();
let delta_bl_class = delta_bl_class.to_string();
let delta_lines_added: Option<i64> = scan_delta.as_ref().map(sum_added_code_lines);
let delta_lines_removed: Option<i64> = scan_delta.as_ref().map(sum_removed_code_lines);
let (delta_lines_net_str, delta_lines_net_class) =
match (delta_lines_added, delta_lines_removed) {
(Some(a), Some(r)) => {
let net = a - r;
(fmt_delta(net), delta_class(net).to_string())
}
_ => ("—".to_string(), "na".to_string()),
};
let run_dir = artifacts.output_dir.clone();
let git_branch = run.git_branch.clone();
let git_commit = run.git_commit_short.clone();
let git_commit_long = run.git_commit_long.clone();
let git_author = run.git_commit_author.clone();
let git_commit_url = run
.git_remote_url
.as_deref()
.zip(run.git_commit_long.as_deref())
.and_then(|(remote, sha)| remote_to_commit_url(remote, sha));
let git_branch_url = run
.git_remote_url
.as_deref()
.zip(run.git_branch.as_deref())
.and_then(|(remote, branch)| remote_to_branch_url(remote, branch));
let scan_performed_by = run.environment.ci_name.clone().unwrap_or_else(|| {
format!(
"{} / {}",
run.environment.initiator_username, run.environment.initiator_hostname
)
});
let scan_time_display = fmt_la_time_meta(run.tool.timestamp_utc);
let os_display = format!(
"{} / {}",
run.environment.operating_system, run.environment.architecture
);
let test_count = run.summary_totals.test_count;
// ── New metrics ──────────────────────────────────────────────────────────
let cyclomatic_complexity = run.summary_totals.cyclomatic_complexity;
let lsloc = run.summary_totals.lsloc;
let uloc = run.uloc;
let dryness_pct_str = run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}"));
let duplicate_group_count = run.duplicate_groups.len();
// Re-compute COCOMO with the mode selected in the scan wizard.
let ctx = &artifacts.result_context;
let (
has_cocomo,
cocomo_effort_str,
cocomo_duration_str,
cocomo_staff_str,
cocomo_ksloc_str,
cocomo_mode_label,
cocomo_mode_tooltip,
) = {
let ksloc = run.summary_totals.code_lines as f64 / 1_000.0;
let mode_str = ctx.cocomo_mode.as_str();
let (a, b, c, d, label, tooltip): (f64, f64, f64, f64, &str, &str) = match mode_str {
"semi_detached" => (3.0, 1.12, 2.5, 0.35, "Semi-detached",
"Semi-detached: A mixed team with varying experience tackling a project with \
moderate novelty and some rigid constraints. Typical for compilers, transaction \
systems, and batch processors. Effort = 3.0 \u{00D7} KSLOC^1.12."),
"embedded" => (3.6, 1.20, 2.5, 0.32, "Embedded",
"Embedded: Tight hardware, software, or operational constraints requiring \
significant innovation and deep integration work. Typical for real-time control \
systems and safety-critical software. Effort = 3.6 \u{00D7} KSLOC^1.20."),
_ => (2.4, 1.05, 2.5, 0.38, "Organic",
"Organic: A small team working on a well-understood project in a familiar \
environment with minimal external constraints. Suited for internal tools, \
utilities, and projects with stable requirements. Effort = 2.4 \u{00D7} KSLOC^1.05."),
};
let effort = a * ksloc.powf(b);
let duration = c * effort.powf(d);
let staff = if duration > 0.0 {
effort / duration
} else {
0.0
};
if run.summary_totals.code_lines > 0 {
(
true,
format!("{:.2}", (effort * 100.0).round() / 100.0),
format!("{:.2}", (duration * 100.0).round() / 100.0),
format!("{:.2}", (staff * 100.0).round() / 100.0),
format!("{:.2}", (ksloc * 100.0).round() / 100.0),
label.to_string(),
tooltip.to_string(),
)
} else {
(
false,
String::new(),
String::new(),
String::new(),
String::new(),
label.to_string(),
tooltip.to_string(),
)
}
};
let complexity_alert = ctx.complexity_alert;
let template = ResultTemplate {
version: env!("CARGO_PKG_VERSION"),
report_title: run.effective_configuration.reporting.report_title.clone(),
project_path: project_path.clone(),
output_dir: display_path(&artifacts.output_dir),
run_id: run_id.to_owned(),
run_id_short: run_id
.split('-')
.next_back()
.unwrap_or(run_id)
.chars()
.take(7)
.collect(),
files_analyzed,
files_skipped,
physical_lines,
code_lines,
comment_lines,
blank_lines,
mixed_lines,
functions,
classes,
variables,
imports,
html_url: artifacts
.html_path
.as_ref()
.map(|_| format!("/runs/html/{run_id}")),
pdf_url: artifacts
.pdf_path
.as_ref()
.map(|_| format!("/runs/pdf/{run_id}")),
json_url: artifacts
.json_path
.as_ref()
.map(|_| format!("/runs/json/{run_id}")),
html_download_url: artifacts
.html_path
.as_ref()
.map(|_| format!("/runs/html/{run_id}?download=1")),
pdf_download_url: artifacts
.pdf_path
.as_ref()
.map(|_| format!("/runs/pdf/{run_id}?download=1")),
json_download_url: artifacts
.json_path
.as_ref()
.map(|_| format!("/runs/json/{run_id}?download=1")),
html_path: artifacts.html_path.as_ref().map(|p| display_path(p)),
json_path: artifacts.json_path.as_ref().map(|p| display_path(p)),
prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
prev_fa_str,
prev_fs_str,
prev_pl_str,
prev_cl_str,
prev_cml_str,
prev_bl_str,
delta_fa_str,
delta_fa_class,
delta_fs_str,
delta_fs_class,
delta_pl_str,
delta_pl_class,
delta_cl_str,
delta_cl_class,
delta_cml_str,
delta_cml_class,
delta_bl_str,
delta_bl_class,
delta_lines_added,
delta_lines_removed,
delta_lines_net_str,
delta_lines_net_class,
delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
delta_unmodified_lines: scan_delta.as_ref().map(|d| {
d.file_deltas
.iter()
.filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
.map(|f| {
#[allow(clippy::cast_sign_loss)]
let n = f.current_code as u64;
n
})
.sum()
}),
git_branch,
git_branch_url,
git_commit,
git_commit_long,
git_author,
git_commit_url,
scan_performed_by,
scan_time_display,
os_display,
test_count,
current_scan_number: prev_scan_count + 1,
prev_scan_count,
submodule_rows: run
.submodule_summaries
.iter()
.map(|s| build_submodule_row(s, run, run_id, &run_dir))
.collect(),
pdf_generating: artifacts.pdf_path.as_ref().is_some_and(|p| !p.exists()),
scan_config_url: format!("/runs/scan-config/{run_id}"),
lang_chart_json: {
let mut langs: Vec<&sloc_core::LanguageSummary> =
run.totals_by_language.iter().collect();
langs.sort_by_key(|l| std::cmp::Reverse(l.code_lines));
let entries: Vec<String> = langs
.into_iter()
.take(12)
.map(|l| {
let name = l
.language
.display_name()
.replace('\\', "\\\\")
.replace('"', "\\\"");
format!(
r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"physical":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
name,
l.code_lines,
l.comment_lines,
l.blank_lines,
l.total_physical_lines,
l.functions,
l.classes,
l.variables,
l.imports,
l.files,
)
})
.collect();
format!("[{}]", entries.join(","))
},
scatter_chart_json: {
let entries: Vec<String> = run
.totals_by_language
.iter()
.map(|l| {
let name = l
.language
.display_name()
.replace('\\', "\\\\")
.replace('"', "\\\"");
format!(
r#"{{"lang":"{}","files":{},"code":{},"physical":{}}}"#,
name, l.files, l.code_lines, l.total_physical_lines,
)
})
.collect();
format!("[{}]", entries.join(","))
},
semantic_chart_json: {
let entries: Vec<String> = run
.totals_by_language
.iter()
.filter(|l| {
l.functions > 0
|| l.classes > 0
|| l.variables > 0
|| l.imports > 0
|| l.test_count > 0
})
.map(|l| {
let name = l
.language
.display_name()
.replace('\\', "\\\\")
.replace('"', "\\\"");
format!(
r#"{{"lang":"{}","functions":{},"classes":{},"variables":{},"imports":{},"tests":{}}}"#,
name, l.functions, l.classes, l.variables, l.imports, l.test_count,
)
})
.collect();
format!("[{}]", entries.join(","))
},
submodule_chart_json: {
let entries: Vec<String> = run
.submodule_summaries
.iter()
.map(|s| {
let name = s.name.replace('\\', "\\\\").replace('"', "\\\"");
format!(
r#"{{"name":"{}","code":{},"comment":{},"blank":{},"physical":{},"files":{}}}"#,
name,
s.code_lines,
s.comment_lines,
s.blank_lines,
s.total_physical_lines,
s.files_analyzed,
)
})
.collect();
format!("[{}]", entries.join(","))
},
has_submodule_data: !run.submodule_summaries.is_empty(),
has_semantic_data: run
.totals_by_language
.iter()
.any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
csp_nonce: csp_nonce.to_owned(),
confluence_configured,
server_mode,
report_header_footer: run
.effective_configuration
.reporting
.report_header_footer
.clone(),
is_offline: false,
cyclomatic_complexity,
lsloc,
uloc,
dryness_pct_str,
duplicate_group_count,
has_cocomo,
cocomo_effort_str,
cocomo_duration_str,
cocomo_staff_str,
cocomo_ksloc_str,
cocomo_mode_label,
cocomo_mode_tooltip,
complexity_alert,
};
Html(
template
.render()
.unwrap_or_else(|err| format!("<pre>{err}</pre>")),
)
.into_response()
}
fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
let slug: String = report_title
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' {
c.to_ascii_lowercase()
} else {
'_'
}
})
.collect::<String>()
.split('_')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("_");
let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
if slug.is_empty() {
format!("report_{short_id}.pdf")
} else {
format!("{slug}_{short_id}.pdf")
}
}
#[derive(Serialize)]
struct PdfStatusResponse {
ready: bool,
}
/// Return `{"ready": true}` once the PDF file exists on disk for a given run.
/// Clients poll this to update the button state without page reloads.
async fn pdf_status_handler(
State(state): State<AppState>,
AxumPath(run_id): AxumPath<String>,
) -> Response {
let pdf_path = {
let registry = state.artifacts.lock().await;
registry.get(&run_id).and_then(|a| a.pdf_path.clone())
};
let pdf_path = if pdf_path.is_some() {
pdf_path
} else {
let reg = state.registry.lock().await;
reg.find_by_run_id(&run_id)
.map(recover_artifacts_from_registry)
.and_then(|a| a.pdf_path)
};
let ready = pdf_path.is_some_and(|p| p.exists());
Json(PdfStatusResponse { ready }).into_response()
}
/// GET /`api/runs/:run_id/bundle`
///
/// Streams a gzip-compressed tar archive containing every artifact in the run's
/// output directory (HTML, PDF, JSON, CSV, XLSX, scan-config JSON). The archive
/// is built in memory so it never touches a temp file.
async fn download_bundle_handler(
State(state): State<AppState>,
AxumPath(run_id): AxumPath<String>,
) -> Response {
// Resolve output directory from in-memory cache or persisted registry.
let output_dir = {
let cache = state.artifacts.lock().await;
cache.get(&run_id).map(|a| a.output_dir.clone())
};
let output_dir = if let Some(d) = output_dir {
d
} else {
let reg = state.registry.lock().await;
match reg.find_by_run_id(&run_id) {
Some(entry) => recover_artifacts_from_registry(entry).output_dir,
None => {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Run not found"})),
)
.into_response();
}
}
};
if !output_dir.exists() {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Output directory no longer exists on disk"})),
)
.into_response();
}
// Build tar.gz in a blocking thread to avoid blocking the async runtime.
let run_id_clone = run_id.clone();
let archive_result = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<u8>> {
use flate2::{write::GzEncoder, Compression};
let mut enc = GzEncoder::new(Vec::new(), Compression::default());
{
let mut tar = tar::Builder::new(&mut enc);
tar.follow_symlinks(false);
// Append every regular file in the output directory, skipping
// sub-directories (the output dir is always flat).
if let Ok(entries) = std::fs::read_dir(&output_dir) {
for entry in entries.filter_map(Result::ok) {
let p = entry.path();
if p.is_file() {
let name = p.file_name().unwrap_or_default().to_string_lossy();
let archive_path = format!("{run_id_clone}/{name}");
tar.append_path_with_name(&p, &archive_path)?;
}
}
}
tar.finish()?;
}
Ok(enc.finish()?)
})
.await;
match archive_result {
Ok(Ok(bytes)) => {
let filename = format!("oxide-sloc-{}.tar.gz", &run_id[..run_id.len().min(8)]);
axum::response::Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/gzip")
.header(
"Content-Disposition",
format!("attachment; filename=\"{filename}\""),
)
.header("Content-Length", bytes.len().to_string())
.body(axum::body::Body::from(bytes))
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
}
Ok(Err(e)) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("Archive build failed: {e}")})),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("Task panicked: {e}")})),
)
.into_response(),
}
}
/// DELETE /`api/runs/:run_id`
///
/// Removes all on-disk artifacts for the run and purges the run from the
/// in-memory cache and the persisted registry. Returns 204 on success.
async fn delete_run_handler(
State(state): State<AppState>,
AxumPath(run_id): AxumPath<String>,
) -> Response {
// Resolve output directory.
let output_dir = {
let mut cache = state.artifacts.lock().await;
let dir = cache.get(&run_id).map(|a| a.output_dir.clone());
cache.remove(&run_id);
dir
};
let output_dir = if let Some(d) = output_dir {
d
} else {
let reg = state.registry.lock().await;
reg.find_by_run_id(&run_id)
.map(|e| recover_artifacts_from_registry(e).output_dir)
.unwrap_or_default()
};
// Remove from persisted registry.
{
let mut reg = state.registry.lock().await;
reg.entries.retain(|e| e.run_id != run_id);
let _ = reg.save(&state.registry_path);
}
// Delete on-disk artifacts. Treat NotFound as success — concurrent tests or
// a prior delete may have already removed the directory.
if output_dir.exists() {
match tokio::fs::remove_dir_all(&output_dir).await {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("Failed to delete files: {e}")})),
)
.into_response();
}
}
}
StatusCode::NO_CONTENT.into_response()
}
/// POST /api/runs/cleanup
///
/// Deletes all runs older than `older_than_days` days (default 30). Removes on-disk artifacts and
/// purges the registry. Returns `{ deleted: N }` with the count of runs removed.
async fn cleanup_runs_handler(
State(state): State<AppState>,
Json(body): Json<serde_json::Value>,
) -> Response {
let days = body
.get("older_than_days")
.and_then(serde_json::Value::as_u64)
.unwrap_or(30)
.max(1);
let cutoff = chrono::Utc::now() - chrono::Duration::days(days.cast_signed());
// Collect expired entries from the registry.
let expired: Vec<(String, PathBuf)> = {
let reg = state.registry.lock().await;
reg.entries
.iter()
.filter(|e| e.timestamp_utc < cutoff)
.map(|e| {
let arts = recover_artifacts_from_registry(e);
(e.run_id.clone(), arts.output_dir)
})
.collect()
};
let mut deleted = 0usize;
for (run_id, output_dir) in &expired {
// Remove from in-memory cache.
state.artifacts.lock().await.remove(run_id);
// Delete on-disk artifacts (non-fatal if already gone).
if output_dir.exists() {
if let Err(e) = tokio::fs::remove_dir_all(output_dir).await {
eprintln!(
"[oxide-sloc] cleanup: failed to remove {}: {e:#}",
output_dir.display()
);
continue;
}
}
deleted += 1;
}
// Purge expired run IDs from the registry in one pass.
let expired_ids: std::collections::HashSet<&str> =
expired.iter().map(|(id, _)| id.as_str()).collect();
{
let mut reg = state.registry.lock().await;
reg.entries
.retain(|e| !expired_ids.contains(e.run_id.as_str()));
let _ = reg.save(&state.registry_path);
}
Json(serde_json::json!({ "deleted": deleted })).into_response()
}
/// Spawns the background auto-cleanup task. Returns a handle so the caller can
/// abort it when the policy is updated or disabled.
fn spawn_cleanup_policy_task(state: AppState) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
loop {
let interval_secs = {
let store = state.cleanup_policy.lock().await;
match &store.policy {
Some(p) if p.enabled => u64::from(p.interval_hours.max(1)) * 3600,
_ => break,
}
};
tokio::time::sleep(Duration::from_secs(interval_secs)).await;
let n = run_auto_cleanup(&state).await;
tracing::info!("[cleanup-policy] scheduled pass: deleted {n} runs");
}
})
}
fn collect_runs_to_delete(
reg: &ScanRegistry,
max_age_days: Option<u32>,
max_run_count: Option<u32>,
) -> std::collections::HashSet<String> {
let mut to_delete = std::collections::HashSet::new();
if let Some(days) = max_age_days {
let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(days));
for e in ®.entries {
if e.timestamp_utc < cutoff {
to_delete.insert(e.run_id.clone());
}
}
}
if let Some(max_count) = max_run_count {
// entries are sorted newest-first; skip the ones we keep
for e in reg.entries.iter().skip(max_count as usize) {
to_delete.insert(e.run_id.clone());
}
}
to_delete
}
async fn delete_run_artifacts(state: &AppState, run_id: &str) {
let output_dir = {
let mut cache = state.artifacts.lock().await;
let d = cache.get(run_id).map(|a| a.output_dir.clone());
cache.remove(run_id);
d
};
let output_dir = if let Some(d) = output_dir {
d
} else {
let reg = state.registry.lock().await;
reg.find_by_run_id(run_id)
.map(|e| recover_artifacts_from_registry(e).output_dir)
.unwrap_or_default()
};
if output_dir.exists() {
let _ = tokio::fs::remove_dir_all(&output_dir).await;
}
}
/// Core cleanup logic shared by the background task and the "Run Now" handler.
/// Applies both the age limit and the count limit, then updates `last_run_at`.
/// Returns the number of runs deleted.
async fn run_auto_cleanup(state: &AppState) -> u32 {
let (max_age_days, max_run_count) = {
let store = state.cleanup_policy.lock().await;
match &store.policy {
Some(p) if p.enabled => (p.max_age_days, p.max_run_count),
_ => return 0,
}
};
let to_delete = {
let reg = state.registry.lock().await;
collect_runs_to_delete(®, max_age_days, max_run_count)
};
for run_id in &to_delete {
delete_run_artifacts(state, run_id).await;
}
// Purge from registry.
if !to_delete.is_empty() {
let mut reg = state.registry.lock().await;
reg.entries.retain(|e| !to_delete.contains(&e.run_id));
let _ = reg.save(&state.registry_path);
}
let deleted = u32::try_from(to_delete.len()).unwrap_or(u32::MAX);
{
let mut store = state.cleanup_policy.lock().await;
store.last_run_at = Some(chrono::Utc::now());
store.last_run_deleted = Some(deleted);
let _ = store.save(&state.cleanup_policy_path);
}
deleted
}
// ── Auto-cleanup policy API ───────────────────────────────────────────────────
/// GET /api/cleanup-policy — returns the current policy and last-run metadata.
async fn api_get_cleanup_policy(State(state): State<AppState>) -> Response {
let store = state.cleanup_policy.lock().await;
Json(serde_json::json!({
"policy": store.policy,
"last_run_at": store.last_run_at,
"last_run_deleted": store.last_run_deleted,
}))
.into_response()
}
/// POST /api/cleanup-policy — save a new policy and (re)start the background task.
async fn api_save_cleanup_policy(
State(state): State<AppState>,
Json(body): Json<CleanupPolicy>,
) -> Response {
// Abort any running task so the new interval takes effect immediately.
{
let mut handle = state.cleanup_task_handle.lock().await;
if let Some(h) = handle.take() {
h.abort();
}
}
{
let mut store = state.cleanup_policy.lock().await;
store.policy = Some(body.clone());
if let Err(e) = store.save(&state.cleanup_policy_path) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
.into_response();
}
}
if body.enabled {
let handle = spawn_cleanup_policy_task(state.clone());
*state.cleanup_task_handle.lock().await = Some(handle);
}
StatusCode::NO_CONTENT.into_response()
}
/// POST /api/cleanup-policy/run-now — trigger an immediate cleanup pass.
async fn api_run_cleanup_now(State(state): State<AppState>) -> Response {
let deleted = run_auto_cleanup(&state).await;
Json(serde_json::json!({ "deleted": deleted })).into_response()
}
/// DELETE /api/cleanup-policy — remove the policy and stop the background task.
async fn api_delete_cleanup_policy(State(state): State<AppState>) -> Response {
{
let mut handle = state.cleanup_task_handle.lock().await;
if let Some(h) = handle.take() {
h.abort();
}
}
{
let mut store = state.cleanup_policy.lock().await;
store.policy = None;
let _ = store.save(&state.cleanup_policy_path);
}
StatusCode::NO_CONTENT.into_response()
}
/// Serve the HTML artifact for a run — view or download.
/// Replace every `nonce="OLD"` attribute in a pre-generated HTML file with
/// `nonce="NEW"` so that inline `<style>` and `<script>` blocks pass the
/// Replace the inline Chart.js `<script>` block in `<head>` with a cacheable static URL.
/// Only called for browser views; downloads keep the self-contained inline version.
fn swap_inline_chart_js_for_static(html: String) -> String {
let Some(head_end) = html.find("</head>") else {
return html;
};
let Some(script_start) = html[..head_end].rfind("<script") else {
return html;
};
let Some(close_offset) = html[script_start..].find("</script>") else {
return html;
};
let block_end = script_start + close_offset + "</script>".len();
format!(
"{}<script src=\"/static/chart-report.js\"></script>{}",
&html[..script_start],
&html[block_end..]
)
}
/// current-request Content-Security-Policy nonce check.
fn patch_html_nonce(html: &str, new_nonce: &str) -> String {
// Find the first nonce value that was baked in at render time.
let Some(start) = html.find("nonce=\"") else {
// Reports generated before nonce support was added have bare <style> and <script>
// tags with no nonce attribute. Inject the nonce so the current-request CSP allows
// the inline blocks — without it the browser blocks all CSS and JS.
return html
.replace("<style>", &format!("<style nonce=\"{new_nonce}\">"))
.replace("<script>", &format!("<script nonce=\"{new_nonce}\">"));
};
let value_start = start + 7; // len(r#"nonce=""#) == 7
let Some(end_offset) = html[value_start..].find('"') else {
return html.to_owned();
};
let old_nonce = &html[value_start..value_start + end_offset];
html.replace(
&format!("nonce=\"{old_nonce}\""),
&format!("nonce=\"{new_nonce}\""),
)
}
fn serve_html_artifact(
path: &Path,
wants_download: bool,
csp_nonce: &str,
run_id: &str,
server_mode: bool,
) -> Response {
match fs::read_to_string(path) {
Ok(raw) => {
// Patch the saved nonce so inline styles/scripts pass CSP.
let content = patch_html_nonce(&raw, csp_nonce);
if wants_download {
// Keep the self-contained inline version for downloads (opened as file://).
(
[
(header::CONTENT_TYPE, "text/html; charset=utf-8"),
(
header::CONTENT_DISPOSITION,
"attachment; filename=report.html",
),
],
content,
)
.into_response()
} else {
// Swap the 202 KB inline Chart.js block for a cacheable static URL so the
// browser caches it after the first view; the HTML response also shrinks.
Html(swap_inline_chart_js_for_static(content)).into_response()
}
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound && !run_id.is_empty() => {
let filename = path.file_name().map_or_else(
|| "report.html".to_string(),
|n| n.to_string_lossy().into_owned(),
);
let html = LocateFileTemplate {
run_id: run_id.to_owned(),
artifact_type: "html".to_string(),
expected_filename: filename,
server_mode,
csp_nonce: csp_nonce.to_owned(),
version: env!("CARGO_PKG_VERSION"),
}
.render()
.unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
(StatusCode::NOT_FOUND, Html(html)).into_response()
}
Err(err) => {
let filename = path.file_name().map_or_else(
|| "report.html".to_string(),
|n| n.to_string_lossy().into_owned(),
);
let msg = format!("HTML report '{filename}' could not be read.\n\nError: {err}");
let html = ErrorTemplate {
message: msg,
last_report_url: Some("/view-reports".to_string()),
last_report_label: Some("View Reports".to_string()),
run_id: None,
error_code: Some(404),
csp_nonce: csp_nonce.to_owned(),
version: env!("CARGO_PKG_VERSION"),
}
.render()
.unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
(StatusCode::NOT_FOUND, Html(html)).into_response()
}
}
}
/// Serve the PDF artifact for a run — inline or download.
fn serve_pdf_artifact(
path: &Path,
report_title: &str,
run_id: &str,
wants_download: bool,
csp_nonce: &str,
) -> Response {
match fs::read(path) {
Ok(bytes) => {
let filename = build_pdf_filename(report_title, run_id);
let disposition = if wants_download {
format!("attachment; filename=\"{filename}\"")
} else {
format!("inline; filename=\"{filename}\"")
};
(
[
(header::CONTENT_TYPE, "application/pdf".to_string()),
(header::CONTENT_DISPOSITION, disposition),
],
bytes,
)
.into_response()
}
Err(err) => {
let filename = path.file_name().map_or_else(
|| "report.pdf".to_string(),
|n| n.to_string_lossy().into_owned(),
);
let msg = format!(
"PDF report '{filename}' could not be read.\n\n\
Error: {err}\n\n\
If you moved or renamed the output folder, the stored path is now stale. \
Use 'Open PDF folder' from the results page to browse the output directory."
);
let html = ErrorTemplate {
message: msg,
last_report_url: Some("/view-reports".to_string()),
last_report_label: Some("View Reports".to_string()),
run_id: Some(run_id.to_owned()),
error_code: Some(404),
csp_nonce: csp_nonce.to_owned(),
version: env!("CARGO_PKG_VERSION"),
}
.render()
.unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
(StatusCode::NOT_FOUND, Html(html)).into_response()
}
}
}
/// Serve the JSON artifact for a run — view or download.
fn serve_json_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
match fs::read(path) {
Ok(bytes) => {
if wants_download {
(
[
(header::CONTENT_TYPE, "application/json; charset=utf-8"),
(
header::CONTENT_DISPOSITION,
"attachment; filename=result.json",
),
],
bytes,
)
.into_response()
} else {
(
[(header::CONTENT_TYPE, "application/json; charset=utf-8")],
bytes,
)
.into_response()
}
}
Err(err) => {
let filename = path.file_name().map_or_else(
|| "result.json".to_string(),
|n| n.to_string_lossy().into_owned(),
);
let msg = format!(
"JSON result '{filename}' could not be read.\n\n\
Error: {err}\n\n\
If you moved or renamed the output folder, the stored path is now stale. \
Use 'Open JSON folder' from the results page to browse the output directory."
);
let html = ErrorTemplate {
message: msg,
last_report_url: Some("/view-reports".to_string()),
last_report_label: Some("View Reports".to_string()),
run_id: None,
error_code: Some(404),
csp_nonce: csp_nonce.to_owned(),
version: env!("CARGO_PKG_VERSION"),
}
.render()
.unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
(StatusCode::NOT_FOUND, Html(html)).into_response()
}
}
}
/// Recover a `RunArtifacts` from the persisted registry for a run ID.
fn recover_artifacts_from_registry(entry: &RegistryEntry) -> RunArtifacts {
// Derive output_dir from stored paths. New layout puts files in subdirs (html/, json/,
// pdf/, excel/), so go up two levels. Old flat layout goes up one level.
let output_dir = entry
.html_path
.as_ref()
.or(entry.json_path.as_ref())
.or(entry.pdf_path.as_ref())
.or(entry.csv_path.as_ref())
.or(entry.xlsx_path.as_ref())
.and_then(|p| {
let parent = p.parent()?;
let parent_name = parent.file_name().and_then(|n| n.to_str()).unwrap_or("");
// New layout: file is in a named subfolder (html/, json/, pdf/, excel/).
if matches!(parent_name, "html" | "json" | "pdf" | "excel") {
parent.parent().map(PathBuf::from)
} else {
Some(parent.to_path_buf())
}
})
.unwrap_or_default();
// Recover pdf_path: use the persisted one, or look for report.pdf
// adjacent to html/json if only the old entries lack it.
let pdf_path = entry.pdf_path.clone().or_else(|| {
let candidate = output_dir.join("report.pdf");
candidate.exists().then_some(candidate)
});
// csv_path / xlsx_path: persisted paths take precedence; fall back to
// scanning the run directory for files matching the expected patterns so
// that runs created before this feature still surface their artifacts.
let scan_dir_for = |ext: &str| -> Option<PathBuf> {
// Check excel/ subfolder (new layout) then root (old layout).
for dir in &[output_dir.join("excel"), output_dir.clone()] {
if let Some(p) = fs::read_dir(dir).ok().and_then(|entries| {
entries
.filter_map(std::result::Result::ok)
.find(|e| {
let n = e.file_name();
let n = n.to_string_lossy();
n.starts_with("report_") && n.ends_with(ext)
})
.map(|e| e.path())
}) {
return Some(p);
}
}
None
};
let csv_path = entry.csv_path.clone().or_else(|| scan_dir_for(".csv"));
let xlsx_path = entry.xlsx_path.clone().or_else(|| scan_dir_for(".xlsx"));
RunArtifacts {
output_dir: output_dir.clone(),
html_path: entry.html_path.clone(),
pdf_path,
json_path: entry.json_path.clone(),
csv_path,
xlsx_path,
scan_config_path: find_scan_config_in_dir(&output_dir),
report_title: entry.project_label.clone(),
result_context: RunResultContext::default(),
}
}
#[allow(clippy::result_large_err)] // axum Response is unavoidably large; boxing adds indirection
async fn resolve_artifact_set(
state: &AppState,
run_id: &str,
csp_nonce: &str,
) -> Result<RunArtifacts, Response> {
let cached = state.artifacts.lock().await.get(run_id).cloned();
if let Some(a) = cached {
return Ok(a);
}
let reg = state.registry.lock().await;
if let Some(entry) = reg.find_by_run_id(run_id) {
return Ok(recover_artifacts_from_registry(entry));
}
drop(reg);
let short_id = &run_id[..run_id.len().min(8)];
let hint = if matches!(
run_id,
"pdf" | "html" | "json" | "csv" | "xlsx" | "scan-config"
) {
format!(
" The URL format appears to be reversed — \
the server expects /runs/{run_id}/{{run_id}}, not /runs/{{run_id}}/{run_id}. \
Use the View Reports page to navigate to your scan."
)
} else {
" The report may have been deleted or the report directory moved. \
Use View Reports to browse your scan history."
.to_string()
};
let error_html = ErrorTemplate {
message: format!("Report not found. \"{short_id}\" is not a recognized run ID.{hint}"),
last_report_url: Some("/view-reports".to_string()),
last_report_label: Some("View Reports".to_string()),
run_id: None,
error_code: Some(404),
csp_nonce: csp_nonce.to_owned(),
version: env!("CARGO_PKG_VERSION"),
}
.render()
.unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
Err((StatusCode::NOT_FOUND, Html(error_html)).into_response())
}
/// Return the path to a run's PDF, queuing background generation when it is missing.
///
/// Returns `Ok(path)` when the PDF is known (it may still be generating).
/// Returns `Err(response)` when there is no JSON source to regenerate from.
async fn resolve_or_queue_pdf(
state: &AppState,
pdf_path: Option<PathBuf>,
json_path: Option<PathBuf>,
output_dir: PathBuf,
run_id: &str,
report_title: &str,
csp_nonce: &str,
) -> Result<PathBuf, Response> {
if let Some(p) = pdf_path {
return Ok(p);
}
let Some(json_src) = json_path.filter(|p| p.exists()) else {
let msg = "PDF report was not generated for this run. \
Re-run the analysis with PDF output enabled."
.to_string();
let html = ErrorTemplate {
message: msg,
last_report_url: Some(format!("/runs/html/{run_id}")),
last_report_label: Some("View HTML Report".to_string()),
run_id: Some(run_id.to_string()),
error_code: Some(404),
csp_nonce: csp_nonce.to_string(),
version: env!("CARGO_PKG_VERSION"),
}
.render()
.unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
};
let pdf_filename = build_pdf_filename(report_title, run_id);
let pdf_dest = output_dir.join(&pdf_filename);
if !pdf_dest.exists() {
// Record the pending path so concurrent requests show the spinner.
{
let mut map = state.artifacts.lock().await;
if let Some(entry) = map.get_mut(run_id) {
entry.pdf_path = Some(pdf_dest.clone());
}
}
{
let mut reg = state.registry.lock().await;
if let Some(e) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
e.pdf_path = Some(pdf_dest.clone());
}
let _ = reg.save(&state.registry_path);
}
spawn_native_pdf_background(
json_src,
pdf_dest.clone(),
run_id.to_string(),
state.artifacts.clone(),
);
}
Ok(pdf_dest)
}
/// Self-refreshing "please wait" page shown while the background PDF task is still running.
fn pdf_generating_response(run_id: &str, csp_nonce: &str) -> Response {
let html = format!(
"<!doctype html><html lang=\"en\"><head>\
<meta charset=utf-8>\
<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
<meta http-equiv=\"refresh\" content=\"5\">\
<title>OxideSLOC | Generating PDF\u{2026}</title>\
<link rel=\"icon\" type=\"image/png\" href=\"/images/logo/small-logo.png\">\
<style nonce=\"{csp_nonce}\">\
:root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;\
--line:#e6d0bf;--line-strong:#dcb89f;--text:#43342d;--muted:#7b675b;\
--nav:#283790;--nav-2:#013e6b;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}}\
body.dark-theme{{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;\
--line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;}}\
*{{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);}}\
.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);}}\
.top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;\
min-height:56px;display:flex;align-items:center;gap:14px;}}\
.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));}}\
.brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}\
.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;}}\
.nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}\
.nav-pill{{display:inline-flex;align-items:center;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;}}\
.nav-pill:hover{{background:rgba(255,255,255,0.18);}}\
.theme-toggle{{width:38px;display:inline-flex;align-items:center;\
justify-content:center;min-height:38px;border-radius:999px;\
border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.08);cursor:pointer;}}\
.theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}\
.theme-toggle .icon-sun{{display:none;}}\
body.dark-theme .theme-toggle .icon-sun{{display:block;}}\
body.dark-theme .theme-toggle .icon-moon{{display:none;}}\
.page{{width:100%;max-width:1720px;margin:0 auto;padding:60px 24px;\
display:flex;align-items:center;justify-content:center;\
min-height:calc(100vh - 56px);}}\
@media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}\
.panel{{background:var(--surface);border:1px solid var(--line);\
border-radius:var(--radius);box-shadow:var(--shadow);\
padding:48px 56px;text-align:center;max-width:480px;width:100%;}}\
.spin-ring{{width:56px;height:56px;border-radius:50%;\
border:5px solid var(--line);border-top-color:var(--oxide-2);\
animation:spin 1s linear infinite;margin:0 auto 28px;}}\
@keyframes spin{{to{{transform:rotate(360deg);}}}}\
h1{{margin:0 0 12px;font-size:22px;font-weight:800;color:var(--text);}}\
p{{color:var(--muted);margin:0 0 28px;font-size:15px;line-height:1.5;}}\
.back-link{{display:inline-flex;align-items:center;justify-content:center;\
min-height:42px;padding:0 20px;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;}}\
.back-link:hover{{background:var(--line);}}\
</style></head>\
<body>\
<div class=\"top-nav\"><div class=\"top-nav-inner\">\
<a class=\"brand\" href=\"/\">\
<img class=\"brand-logo\" src=\"/images/logo/small-logo.png\" alt=\"OxideSLOC logo\" />\
<div class=\"brand-copy\">\
<div class=\"brand-title\">OxideSLOC</div>\
<div class=\"brand-subtitle\">local code analysis - metrics, history and reports</div>\
</div>\
</a>\
<div class=\"nav-right\">\
<a class=\"nav-pill\" href=\"/\">Home</a>\
<a class=\"nav-pill\" href=\"/view-reports\">View Reports</a>\
<a class=\"nav-pill\" href=\"/compare-scans\">Compare Scans</a>\
<button type=\"button\" class=\"theme-toggle\" id=\"theme-toggle\" aria-label=\"Toggle theme\">\
<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>\
<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>\
</button>\
</div>\
</div></div>\
<div class=\"page\"><div class=\"panel\">\
<div class=\"spin-ring\"></div>\
<h1>Generating PDF\u{2026}</h1>\
<p>The PDF is being generated from the scan results.<br>\
This page refreshes automatically \u{2014} usually a few seconds.</p>\
<a class=\"back-link\" href=\"/runs/pdf/{run_id}\">Refresh now</a>\
</div></div>\
<script nonce=\"{csp_nonce}\">\
(function(){{\
var k=\"oxide-theme\",b=document.body,s=localStorage.getItem(k);\
if(s===\"dark\")b.classList.add(\"dark-theme\");\
var t=document.getElementById(\"theme-toggle\");\
if(t)t.addEventListener(\"click\",function(){{\
var d=b.classList.toggle(\"dark-theme\");\
localStorage.setItem(k,d?\"dark\":\"light\");\
}});\
}})();\
</script>\
</body></html>"
);
Html(html).into_response()
}
/// Render an `ErrorTemplate` to an HTML string; used by artifact download arms.
fn render_error_artifact_html(
message: String,
last_report_url: Option<String>,
last_report_label: Option<String>,
run_id: Option<String>,
error_code: Option<u16>,
csp_nonce: &str,
) -> String {
ErrorTemplate {
message,
last_report_url,
last_report_label,
run_id,
error_code,
csp_nonce: csp_nonce.to_owned(),
version: env!("CARGO_PKG_VERSION"),
}
.render()
.unwrap_or_else(|_| "<pre>Error.</pre>".to_string())
}
/// Read a file and serve it as an attachment download.
fn serve_binary_download(path: &Path, content_type: &str, fallback_filename: &str) -> Response {
fs::read(path).map_or_else(
|_| StatusCode::NOT_FOUND.into_response(),
|bytes| {
let filename = path.file_name().map_or_else(
|| fallback_filename.to_string(),
|n| n.to_string_lossy().into_owned(),
);
(
[
(header::CONTENT_TYPE, content_type.to_string()),
(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{filename}\""),
),
],
bytes,
)
.into_response()
},
)
}
fn serve_csv_arm(csv_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
let Some(path) = csv_path else {
let html = render_error_artifact_html(
"CSV report was not generated for this run, or was not recorded in \
the scan registry."
.to_string(),
Some(format!("/runs/html/{run_id}")),
Some("View HTML Report".to_string()),
Some(run_id.to_string()),
Some(404),
csp_nonce,
);
return (StatusCode::NOT_FOUND, Html(html)).into_response();
};
serve_binary_download(&path, "text/csv; charset=utf-8", "report.csv")
}
fn serve_xlsx_arm(xlsx_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
let Some(path) = xlsx_path else {
let html = render_error_artifact_html(
"Excel report was not generated for this run, or was not recorded in \
the scan registry."
.to_string(),
Some(format!("/runs/html/{run_id}")),
Some("View HTML Report".to_string()),
Some(run_id.to_string()),
Some(404),
csp_nonce,
);
return (StatusCode::NOT_FOUND, Html(html)).into_response();
};
serve_binary_download(
&path,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"report.xlsx",
)
}
fn serve_scan_config_arm(artifact_set: &RunArtifacts) -> Response {
let path = artifact_set
.scan_config_path
.as_deref()
.map(std::path::Path::to_path_buf)
.or_else(|| find_scan_config_in_dir(&artifact_set.output_dir))
.unwrap_or_else(|| artifact_set.output_dir.join("scan-config.json"));
fs::read(&path).map_or_else(
|_| StatusCode::NOT_FOUND.into_response(),
|bytes| {
(
[
(
header::CONTENT_TYPE,
"application/json; charset=utf-8".to_string(),
),
(
header::CONTENT_DISPOSITION,
"attachment; filename=\"scan-config.json\"".to_string(),
),
],
bytes,
)
.into_response()
},
)
}
/// Serve a per-submodule PDF using the programmatic renderer (`write_pdf_from_run`).
/// The PDF is pre-generated at scan time; if missing it is rebuilt on demand from the
/// parent JSON + submodule summary. Chrome is never involved for sub-report PDFs.
/// Artifact format: `sub_{safe}_pdf` — strips the `_pdf` suffix to locate the file.
async fn serve_submodule_pdf_arm(
artifact: &str,
artifact_set: RunArtifacts,
wants_download: bool,
run_id: &str,
csp_nonce: &str,
) -> Response {
// "sub_benchmark_pdf" → base = "sub_benchmark"
let base = artifact.trim_end_matches("_pdf");
let sub_dir = artifact_set.output_dir.join("submodules");
let pdf_path = sub_dir.join(format!("{base}.pdf"));
if !pdf_path.exists() {
// On-demand fallback: rebuild the sub-run from the parent JSON and regenerate.
let derived_safe = base.trim_start_matches("sub_");
let rebuilt = artifact_set.json_path.as_deref().and_then(|jp| {
let parent_run = read_json(jp).ok()?;
let sub = parent_run
.submodule_summaries
.iter()
.find(|s| sanitize_project_label(&s.name) == derived_safe)?
.clone();
let parent_path = parent_run.input_roots.first().cloned().unwrap_or_default();
Some((parent_run, sub, parent_path))
});
if let Some((parent_run, sub, parent_path)) = rebuilt {
let sub_run = build_sub_run(&parent_run, &sub, &parent_path);
let pp = pdf_path.clone();
let _ = tokio::task::spawn_blocking(move || write_pdf_from_run(&sub_run, &pp)).await;
}
}
if !pdf_path.exists() {
let html = render_error_artifact_html(
"Sub-report PDF could not be generated — re-run the scan with submodule breakdown \
enabled."
.to_string(),
Some("/view-reports".to_string()),
Some("View Reports".to_string()),
Some(run_id.to_string()),
Some(404),
csp_nonce,
);
return (StatusCode::NOT_FOUND, Html(html)).into_response();
}
serve_pdf_artifact(
&pdf_path,
&artifact_set.report_title,
run_id,
wants_download,
csp_nonce,
)
}
fn serve_submodule_arm(
artifact: &str,
artifact_set: &RunArtifacts,
wants_download: bool,
csp_nonce: &str,
run_id: &str,
server_mode: bool,
) -> Response {
if artifact.len() > 128
|| !artifact
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
return StatusCode::BAD_REQUEST.into_response();
}
let filename = format!("{artifact}.html");
// Check submodules/ subfolder first (new layout), fall back to root (old layout).
let new_layout = artifact_set.output_dir.join("submodules").join(&filename);
let path = if new_layout.exists() {
new_layout
} else {
artifact_set.output_dir.join(&filename)
};
if !path.exists() {
let html = render_error_artifact_html(
format!(
"Sub-report '{artifact}' was not found in the run directory.\n\
Re-run the analysis with 'Detect and separate git submodules' \
and HTML output enabled."
),
Some("/view-reports".to_string()),
Some("View Reports".to_string()),
Some(run_id.to_string()),
Some(404),
csp_nonce,
);
return (StatusCode::NOT_FOUND, Html(html)).into_response();
}
serve_html_artifact(&path, wants_download, csp_nonce, run_id, server_mode)
}
async fn serve_pdf_arm(
state: &AppState,
artifact_set: RunArtifacts,
wants_download: bool,
run_id: &str,
csp_nonce: &str,
) -> Response {
let report_title = artifact_set.report_title.clone();
let had_pdf_in_registry = artifact_set.pdf_path.is_some();
let stale_html_name = artifact_set
.html_path
.as_deref()
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().into_owned());
let path = match resolve_or_queue_pdf(
state,
artifact_set.pdf_path,
artifact_set.json_path.clone(),
artifact_set.output_dir.clone(),
run_id,
&report_title,
csp_nonce,
)
.await
{
Ok(p) => p,
Err(r) => return r,
};
if !path.exists() {
// Distinguish a stale registry path (folder moved) from an in-progress
// background generation. Only show the locate page when the PDF was
// already recorded in the registry but the file is now missing.
if had_pdf_in_registry {
if let Some(expected_filename) = stale_html_name {
let html = LocateFileTemplate {
run_id: run_id.to_string(),
artifact_type: "pdf".to_string(),
expected_filename,
server_mode: state.server_mode,
csp_nonce: csp_nonce.to_string(),
version: env!("CARGO_PKG_VERSION"),
}
.render()
.unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
return (StatusCode::NOT_FOUND, Html(html)).into_response();
}
}
return pdf_generating_response(run_id, csp_nonce);
}
serve_pdf_artifact(&path, &report_title, run_id, wants_download, csp_nonce)
}
async fn artifact_handler(
State(state): State<AppState>,
axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
AxumPath((artifact, run_id)): AxumPath<(String, String)>,
Query(query): Query<ArtifactQuery>,
) -> Response {
let artifact_set = match resolve_artifact_set(&state, &run_id, &csp_nonce).await {
Ok(a) => a,
Err(r) => return r,
};
let wants_download = matches!(query.download.as_deref(), Some("1" | "true" | "yes"));
match artifact.as_str() {
"html" => {
let Some(path) = artifact_set.html_path else {
return StatusCode::NOT_FOUND.into_response();
};
serve_html_artifact(
&path,
wants_download,
&csp_nonce,
&run_id,
state.server_mode,
)
}
"pdf" => serve_pdf_arm(&state, artifact_set, wants_download, &run_id, &csp_nonce).await,
"json" => {
let Some(path) = artifact_set.json_path else {
let html = render_error_artifact_html(
"JSON result was not generated for this run, or was not recorded in \
the scan registry. Re-run the analysis with JSON output enabled."
.to_string(),
Some("/view-reports".to_string()),
Some("View Reports".to_string()),
Some(run_id.clone()),
Some(404),
&csp_nonce,
);
return (StatusCode::NOT_FOUND, Html(html)).into_response();
};
serve_json_artifact(&path, wants_download, &csp_nonce)
}
"csv" => serve_csv_arm(artifact_set.csv_path, &run_id, &csp_nonce),
"xlsx" => serve_xlsx_arm(artifact_set.xlsx_path, &run_id, &csp_nonce),
"scan-config" => serve_scan_config_arm(&artifact_set),
_ if artifact.starts_with("sub_") && artifact.ends_with("_pdf") => {
serve_submodule_pdf_arm(&artifact, artifact_set, wants_download, &run_id, &csp_nonce)
.await
}
_ if artifact.starts_with("sub_") => serve_submodule_arm(
&artifact,
&artifact_set,
wants_download,
&csp_nonce,
&run_id,
state.server_mode,
),
_ => StatusCode::NOT_FOUND.into_response(),
}
}
// ── History ───────────────────────────────────────────────────────────────────
struct SubmoduleLinkRow {
name: String,
url: String,
}
struct HistoryEntryRow {
run_id: String,
run_id_short: String,
timestamp: String,
timestamp_utc_ms: i64,
project_label: String,
project_path: String,
files_analyzed: u64,
files_skipped: u64,
code_lines: u64,
comment_lines: u64,
blank_lines: u64,
git_branch: String,
git_commit: String,
has_html: bool,
has_json: bool,
has_pdf: bool,
submodule_links: Vec<SubmoduleLinkRow>,
/// Comma-separated submodule names used as a `data-submodules` HTML attribute.
submodule_names_csv: String,
}
/// Returns the nth occurrence of `weekday` in the given month/year (1-based).
fn nth_weekday_of_month(
year: i32,
month: u32,
weekday: chrono::Weekday,
n: u32,
) -> chrono::NaiveDate {
use chrono::Datelike;
let mut count = 0u32;
let mut day = 1u32;
loop {
let d = chrono::NaiveDate::from_ymd_opt(year, month, day).expect("valid date");
if d.weekday() == weekday {
count += 1;
if count == n {
return d;
}
}
day += 1;
}
}
/// Returns true if `dt` falls within US Pacific Daylight Time.
/// DST starts: second Sunday in March at 02:00 PST = 10:00 UTC.
/// DST ends: first Sunday in November at 02:00 PDT = 09:00 UTC.
fn is_pacific_dst(dt: chrono::DateTime<chrono::Utc>) -> bool {
use chrono::{Datelike, TimeZone};
let year = dt.year();
let dst_start = chrono::Utc.from_utc_datetime(
&nth_weekday_of_month(year, 3, chrono::Weekday::Sun, 2)
.and_time(chrono::NaiveTime::from_hms_opt(10, 0, 0).expect("valid")),
);
let dst_end = chrono::Utc.from_utc_datetime(
&nth_weekday_of_month(year, 11, chrono::Weekday::Sun, 1)
.and_time(chrono::NaiveTime::from_hms_opt(9, 0, 0).expect("valid")),
);
dt >= dst_start && dt < dst_end
}
fn fmt_la_time(dt: chrono::DateTime<chrono::Utc>) -> String {
if is_pacific_dst(dt) {
dt.with_timezone(&chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"))
.format("%Y-%m-%d %H:%M PDT")
.to_string()
} else {
dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"))
.format("%Y-%m-%d %H:%M PST")
.to_string()
}
}
/// Format a timestamp for the result-page meta row (seconds precision, PDT/PST label).
fn fmt_la_time_meta(dt: chrono::DateTime<chrono::Utc>) -> String {
let (offset, tz) = if is_pacific_dst(dt) {
(
chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"),
"PDT",
)
} else {
(
chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"),
"PST",
)
};
format!(
"{} {tz}",
dt.with_timezone(&offset).format("%Y-%m-%d %H:%M:%S")
)
}
fn fmt_git_date(iso: &str) -> Option<String> {
chrono::DateTime::parse_from_rfc3339(iso)
.ok()
.map(|d| fmt_la_time(d.with_timezone(&chrono::Utc)))
}
fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
reg.entries
.iter()
.map(|e| {
let submodule_links = {
let mut links: Vec<SubmoduleLinkRow> = vec![];
let sub_dir = e
.html_path
.as_ref()
.and_then(|p| p.parent())
.or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
if let Some(dir) = sub_dir {
if let Ok(rd) = std::fs::read_dir(dir) {
for entry_res in rd.flatten() {
let fname = entry_res.file_name();
let fname_str = fname.to_string_lossy();
if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
let stem = &fname_str[..fname_str.len() - 5];
let display = stem[4..].replace('-', " ");
links.push(SubmoduleLinkRow {
name: display,
url: format!("/runs/{stem}/{}", e.run_id),
});
}
}
}
}
links.sort_by(|a, b| a.name.cmp(&b.name));
links
};
let submodule_names_csv = submodule_links
.iter()
.map(|l| l.name.as_str())
.collect::<Vec<_>>()
.join(",");
HistoryEntryRow {
run_id: e.run_id.clone(),
run_id_short: e
.run_id
.split('-')
.next_back()
.unwrap_or(&e.run_id)
.chars()
.take(7)
.collect(),
timestamp: fmt_la_time(e.timestamp_utc),
timestamp_utc_ms: e.timestamp_utc.timestamp_millis(),
project_label: e.project_label.clone(),
project_path: e
.input_roots
.first()
.map(|s| sanitize_path_str(s))
.unwrap_or_default(),
files_analyzed: e.summary.files_analyzed,
files_skipped: e.summary.files_skipped,
code_lines: e.summary.code_lines,
comment_lines: e.summary.comment_lines,
blank_lines: e.summary.blank_lines,
git_branch: e.git_branch.clone().unwrap_or_default(),
git_commit: e.git_commit.clone().unwrap_or_default(),
has_html: e.html_path.as_ref().is_some_and(|p| p.exists()),
has_json: e.json_path.as_ref().is_some_and(|p| p.exists()),
has_pdf: e.pdf_path.as_ref().is_some_and(|p| p.exists()),
submodule_links,
submodule_names_csv,
}
})
.collect()
}
#[derive(Deserialize, Default)]
struct HistoryQuery {
linked: Option<String>,
error: Option<String>,
}
async fn history_handler(
State(state): State<AppState>,
axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
Query(query): Query<HistoryQuery>,
) -> impl IntoResponse {
// Auto-scan all watched directories before rendering so the list stays fresh.
auto_scan_watched_dirs(&state).await;
let watched_dirs: Vec<String> = {
let wd = state.watched_dirs.lock().await;
wd.dirs.iter().map(|p| p.display().to_string()).collect()
};
let mut entries = {
let reg = state.registry.lock().await;
make_history_rows(®)
};
entries.retain(|e| e.has_html);
let total_scans = entries.len();
let linked_count = query
.linked
.as_deref()
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(0);
let browse_error = query.error.filter(|s| !s.is_empty());
let template = HistoryTemplate {
version: env!("CARGO_PKG_VERSION"),
entries,
total_scans,
linked_count,
browse_error,
watched_dirs,
csp_nonce,
server_mode: state.server_mode,
};
Html(
template
.render()
.unwrap_or_else(|e| format!("<pre>{e}</pre>")),
)
.into_response()
}
async fn compare_select_handler(
State(state): State<AppState>,
axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
) -> impl IntoResponse {
auto_scan_watched_dirs(&state).await;
let watched_dirs: Vec<String> = {
let wd = state.watched_dirs.lock().await;
wd.dirs.iter().map(|p| p.display().to_string()).collect()
};
let mut entries = {
let reg = state.registry.lock().await;
make_history_rows(®)
};
entries.retain(|e| e.has_json);
let total_scans = entries.len();
let template = CompareSelectTemplate {
version: env!("CARGO_PKG_VERSION"),
entries,
total_scans,
watched_dirs,
csp_nonce,
server_mode: state.server_mode,
};
Html(
template
.render()
.unwrap_or_else(|e| format!("<pre>{e}</pre>")),
)
.into_response()
}
// ── Compare ───────────────────────────────────────────────────────────────────
#[derive(Deserialize, Default)]
struct CompareQuery {
a: Option<String>,
b: Option<String>,
/// Optional submodule name to scope the comparison to one submodule.
sub: Option<String>,
/// "super" to exclude all submodule files and show only the super-repo.
scope: Option<String>,
}
struct CompareFileDeltaRow {
relative_path: String,
language: String,
status: String,
baseline_code: i64,
current_code: i64,
baseline_code_display: String,
current_code_display: String,
code_delta_str: String,
code_delta_class: String,
comment_delta_str: String,
comment_delta_class: String,
total_delta_str: String,
total_delta_class: String,
}
/// Recompute `summary_totals` from the current `per_file_records` slice.
/// Used when `per_file_records` has been narrowed to a submodule subset.
fn recompute_summary_from_records(run: &mut AnalysisRun) {
let mut totals = SummaryTotals::default();
for r in &run.per_file_records {
if r.language.is_some() {
totals.files_analyzed += 1;
}
totals.total_physical_lines += r.raw_line_categories.total_physical_lines;
totals.code_lines += r.effective_counts.code_lines;
totals.comment_lines += r.effective_counts.comment_lines;
totals.blank_lines += r.effective_counts.blank_lines;
totals.mixed_lines_separate += r.effective_counts.mixed_lines_separate;
totals.functions += r.raw_line_categories.functions;
totals.classes += r.raw_line_categories.classes;
totals.variables += r.raw_line_categories.variables;
totals.imports += r.raw_line_categories.imports;
totals.test_count += r.raw_line_categories.test_count;
totals.test_assertion_count += r.raw_line_categories.test_assertion_count;
totals.test_suite_count += r.raw_line_categories.test_suite_count;
if let Some(cov) = &r.coverage {
totals.coverage_lines_found += u64::from(cov.lines_found);
totals.coverage_lines_hit += u64::from(cov.lines_hit);
totals.coverage_functions_found += u64::from(cov.functions_found);
totals.coverage_functions_hit += u64::from(cov.functions_hit);
totals.coverage_branches_found += u64::from(cov.branches_found);
totals.coverage_branches_hit += u64::from(cov.branches_hit);
}
}
totals.files_considered = totals.files_analyzed;
run.summary_totals = totals;
}
fn fmt_delta(n: i64) -> String {
if n > 0 {
format!("+{n}")
} else {
format!("{n}")
}
}
fn delta_class(n: i64) -> &'static str {
use std::cmp::Ordering;
match n.cmp(&0) {
Ordering::Greater => "pos",
Ordering::Less => "neg",
Ordering::Equal => "zero",
}
}
// ratio/percentage display, precision loss acceptable
#[allow(clippy::cast_precision_loss)]
fn fmt_pct(delta: i64, baseline: u64) -> String {
if baseline == 0 {
return "—".to_string();
}
#[allow(clippy::cast_precision_loss)]
let pct = (delta as f64 / baseline as f64) * 100.0;
if pct > 0.049 {
format!("+{pct:.1}%")
} else if pct < -0.049 {
format!("{pct:.1}%")
} else {
"±0%".to_string()
}
}
/// Returns (`display_string`, `css_class`) for a numeric change column cell.
fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
prev.map_or_else(
|| ("—".to_string(), "na"),
|p| {
#[allow(clippy::cast_possible_wrap)]
let d = curr as i64 - p as i64;
(fmt_delta(d), delta_class(d))
},
)
}
#[allow(clippy::result_large_err)] // axum::Response is large by design; boxing would change the call pattern
fn load_scan_for_compare(
json_path: &std::path::Path,
scan_label: &str,
run_id: &str,
server_mode: bool,
compare_url: &str,
csp_nonce: &str,
) -> Result<sloc_core::AnalysisRun, axum::response::Response> {
match read_json(json_path) {
Ok(r) => Ok(r),
Err(e) => {
if server_mode {
let html = ErrorTemplate {
message: format!(
"Could not load {scan_label} scan data. The scan output folder may have \
been moved, renamed, or deleted. Re-running the analysis will create \
fresh comparison data."
),
last_report_url: Some("/compare-scans".to_string()),
last_report_label: Some("Compare Scans".to_string()),
run_id: Some(run_id.to_owned()),
error_code: Some(404),
csp_nonce: csp_nonce.to_owned(),
version: env!("CARGO_PKG_VERSION"),
}
.render()
.unwrap_or_else(|_| format!("<pre>{scan_label} load failed.</pre>"));
return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
}
let msg = format!(
"Could not load {scan_label} scan data.\n\nExpected path: {}\n\nError: {e}",
json_path.display()
);
let folder_hint = json_path
.parent()
.map(|p| p.display().to_string())
.unwrap_or_default();
Err(missing_scan_relocate_response(
&msg,
run_id,
&folder_hint,
compare_url,
false,
csp_nonce,
))
}
}
}
struct ChurnStats {
new_scope: bool,
scope_flag: bool,
churn_rate_str: String,
churn_rate_class: String,
}
fn compute_churn_stats(
baseline_code: u64,
current_code: u64,
lines_added: i64,
lines_removed: i64,
) -> ChurnStats {
let new_scope = baseline_code == 0 && current_code > 0;
#[allow(clippy::cast_precision_loss)]
let churn_pct = if baseline_code > 0 {
(lines_added + lines_removed) as f64 / baseline_code as f64 * 100.0
} else {
0.0
};
#[allow(clippy::cast_precision_loss)]
let scope_flag =
new_scope || (baseline_code > 0 && lines_added as f64 / baseline_code as f64 > 0.20);
let churn_rate_str = if new_scope {
"New".to_string()
} else if baseline_code > 0 {
format!("{churn_pct:.1}%")
} else {
"—".to_string()
};
let churn_rate_class = if new_scope || churn_pct > 20.0 {
"high".to_string()
} else if churn_pct > 5.0 {
"med".to_string()
} else {
"low".to_string()
};
ChurnStats {
new_scope,
scope_flag,
churn_rate_str,
churn_rate_class,
}
}
/// Build a pre-rendered HTML delta card for line coverage, or an empty string when neither
/// scan has coverage data. Using a pre-built HTML string avoids adding multiple Askama template
/// variables to the large `CompareTemplate`, which causes rustc stack overflows on Windows.
fn build_coverage_delta_card(s: &sloc_core::SummaryDelta) -> String {
let has_data = s.baseline_coverage_line_pct.is_some() || s.current_coverage_line_pct.is_some();
if !has_data {
return String::new();
}
let base_str = s
.baseline_coverage_line_pct
.map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
let curr_str = s
.current_coverage_line_pct
.map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
let (delta_str, cls) = match s.coverage_line_pct_delta {
Some(d) if d > 0.0 => (format!("+{d:.1} pp"), "pos"),
Some(d) if d < 0.0 => (format!("{d:.1} pp"), "neg"),
Some(_) => ("\u{00b1}0.0 pp".into(), "zero"),
None => ("\u{2014}".into(), "zero"),
};
format!(
r#"<div class="delta-card">
<div class="dc-tip">Line coverage % from LCOV/Cobertura/JaCoCo.<br>Positive delta = more lines instrumented and hit.<br>Only shown when at least one scan has coverage data.</div>
<div class="delta-card-label">Line coverage</div>
<div class="delta-card-from">Before: {base_str}</div>
<div class="delta-card-to">{curr_str}</div>
<span class="delta-card-change {cls}">{delta_str}</span>
</div>"#
)
}
/// Filter baseline/current run pair to a single submodule scope or super-repo scope.
#[allow(clippy::ref_option)]
fn narrow_run_pair_by_scope(
mut baseline: AnalysisRun,
mut current: AnalysisRun,
active_sub: &Option<String>,
super_scope: bool,
) -> (AnalysisRun, AnalysisRun) {
if let Some(ref sub_name) = active_sub {
baseline
.per_file_records
.retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
current
.per_file_records
.retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
recompute_summary_from_records(&mut baseline);
recompute_summary_from_records(&mut current);
} else if super_scope {
baseline.per_file_records.retain(|f| f.submodule.is_none());
current.per_file_records.retain(|f| f.submodule.is_none());
recompute_summary_from_records(&mut baseline);
recompute_summary_from_records(&mut current);
}
(baseline, current)
}
/// Filter all runs in a multi-compare to a single submodule scope or super-repo scope.
#[allow(clippy::ref_option)]
fn apply_scope_filter(runs: &mut [AnalysisRun], active_sub: &Option<String>, super_scope: bool) {
if let Some(ref sub_name) = active_sub {
for run in runs.iter_mut() {
run.per_file_records
.retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
recompute_summary_from_records(run);
}
} else if super_scope {
for run in runs.iter_mut() {
run.per_file_records.retain(|f| f.submodule.is_none());
recompute_summary_from_records(run);
}
}
}
#[allow(clippy::too_many_lines)]
async fn compare_handler(
State(state): State<AppState>,
axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
Query(query): Query<CompareQuery>,
) -> impl IntoResponse {
// When invoked without run IDs (e.g. clicking the Compare nav link directly)
// redirect to the history page where the user can select two runs.
let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
(Some(a), Some(b)) => (a.to_string(), b.to_string()),
_ => return axum::response::Redirect::to("/compare-scans").into_response(),
};
let (maybe_a, maybe_b) = {
let reg = state.registry.lock().await;
(
reg.find_by_run_id(&run_id_a).cloned(),
reg.find_by_run_id(&run_id_b).cloned(),
)
};
let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
let html = ErrorTemplate {
message: "One or both run IDs were not found in scan history. \
The runs may have been deleted or the registry may have been reset."
.to_string(),
last_report_url: Some("/compare-scans".to_string()),
last_report_label: Some("Compare Scans".to_string()),
run_id: None,
error_code: None,
csp_nonce: csp_nonce.clone(),
version: env!("CARGO_PKG_VERSION"),
}
.render()
.unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
return Html(html).into_response();
};
// Ensure older scan is always the baseline.
let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
(entry_a, entry_b)
} else {
(entry_b, entry_a)
};
// If query params were in the wrong order, redirect to canonical URL so the
// browser always shows the same URL for the same two scans regardless of how
// the user arrived here (Full diff button vs. Compare Scans selection).
if baseline_entry.run_id != run_id_a {
let canonical = format!(
"/compare?a={}&b={}",
baseline_entry.run_id, current_entry.run_id
);
return axum::response::Redirect::to(&canonical).into_response();
}
let (Some(base_json), Some(curr_json)) = (
baseline_entry.json_path.as_ref(),
current_entry.json_path.as_ref(),
) else {
let html = ErrorTemplate {
message: "Full comparison requires JSON scan data, which was not saved for one or \
both of these runs. JSON is now always saved for new scans — re-run the \
affected projects to enable comparisons."
.to_string(),
last_report_url: Some("/compare-scans".to_string()),
last_report_label: Some("Compare Scans".to_string()),
run_id: None,
error_code: None,
csp_nonce: csp_nonce.clone(),
version: env!("CARGO_PKG_VERSION"),
}
.render()
.unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
return Html(html).into_response();
};
let compare_url = format!(
"/compare?a={}&b={}",
baseline_entry.run_id, current_entry.run_id
);
let baseline_run = match load_scan_for_compare(
base_json,
"baseline",
&baseline_entry.run_id,
state.server_mode,
&compare_url,
&csp_nonce,
) {
Ok(r) => r,
Err(resp) => return resp,
};
let current_run = match load_scan_for_compare(
curr_json,
"current",
¤t_entry.run_id,
state.server_mode,
&compare_url,
&csp_nonce,
) {
Ok(r) => r,
Err(resp) => return resp,
};
let active_submodule = query.sub.clone();
let super_scope_active = query.scope.as_deref() == Some("super");
let submodule_options = baseline_run
.submodule_summaries
.iter()
.chain(current_run.submodule_summaries.iter())
.map(|s| s.name.clone())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>();
let has_any_submodule_data = !submodule_options.is_empty();
// Narrow per_file_records when a scope is active, then recompute totals.
let (effective_baseline, effective_current) = narrow_run_pair_by_scope(
baseline_run,
current_run,
&active_submodule,
super_scope_active,
);
let comparison = compute_delta(&effective_baseline, &effective_current);
let file_rows: Vec<CompareFileDeltaRow> = comparison
.file_deltas
.iter()
.map(|d| CompareFileDeltaRow {
relative_path: d.relative_path.clone(),
language: d.language.clone().unwrap_or_else(|| "—".into()),
status: match d.status {
FileChangeStatus::Added => "added".into(),
FileChangeStatus::Removed => "removed".into(),
FileChangeStatus::Modified => "modified".into(),
FileChangeStatus::Unchanged => "unchanged".into(),
},
baseline_code: d.baseline_code,
current_code: d.current_code,
baseline_code_display: if d.status == FileChangeStatus::Added {
"—".into()
} else {
d.baseline_code.to_string()
},
current_code_display: if d.status == FileChangeStatus::Removed {
"—".into()
} else {
d.current_code.to_string()
},
code_delta_str: fmt_delta(d.code_delta),
code_delta_class: delta_class(d.code_delta).into(),
comment_delta_str: fmt_delta(d.comment_delta),
comment_delta_class: delta_class(d.comment_delta).into(),
total_delta_str: fmt_delta(d.total_delta),
total_delta_class: delta_class(d.total_delta).into(),
})
.collect();
let project_path = baseline_entry
.input_roots
.first()
.map(|s| sanitize_path_str(s))
.unwrap_or_default();
let lines_added = sum_added_code_lines(&comparison);
let lines_removed = sum_removed_code_lines(&comparison);
let churn = compute_churn_stats(
comparison.summary.baseline_code,
comparison.summary.current_code,
lines_added,
lines_removed,
);
let s = &comparison.summary;
let template = CompareTemplate {
version: env!("CARGO_PKG_VERSION"),
project_label: baseline_entry.project_label.clone(),
baseline_git_commit: baseline_entry.git_commit.clone().unwrap_or_default(),
current_git_commit: current_entry.git_commit.clone().unwrap_or_default(),
baseline_run_id: baseline_entry.run_id.clone(),
current_run_id: current_entry.run_id.clone(),
baseline_run_id_short: baseline_entry
.run_id
.split('-')
.next_back()
.unwrap_or(&baseline_entry.run_id)
.chars()
.take(7)
.collect(),
current_run_id_short: current_entry
.run_id
.split('-')
.next_back()
.unwrap_or(¤t_entry.run_id)
.chars()
.take(7)
.collect(),
baseline_timestamp: fmt_la_time(baseline_entry.timestamp_utc),
baseline_timestamp_utc_ms: baseline_entry.timestamp_utc.timestamp_millis(),
current_timestamp: fmt_la_time(current_entry.timestamp_utc),
current_timestamp_utc_ms: current_entry.timestamp_utc.timestamp_millis(),
project_path: project_path.clone(),
baseline_code: s.baseline_code,
current_code: s.current_code,
code_lines_delta_str: fmt_delta(s.code_lines_delta),
code_lines_delta_class: delta_class(s.code_lines_delta).into(),
baseline_files: s.baseline_files,
current_files: s.current_files,
files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
baseline_comments: s.baseline_comments,
current_comments: s.current_comments,
comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
baseline_code_fmt: fmt_comma(s.baseline_code.cast_signed()),
current_code_fmt: fmt_comma(s.current_code.cast_signed()),
baseline_files_fmt: fmt_comma(s.baseline_files.cast_signed()),
current_files_fmt: fmt_comma(s.current_files.cast_signed()),
baseline_comments_fmt: fmt_comma(s.baseline_comments.cast_signed()),
current_comments_fmt: fmt_comma(s.current_comments.cast_signed()),
code_lines_pct_str: fmt_pct(s.code_lines_delta, s.baseline_code),
files_analyzed_pct_str: fmt_pct(s.files_analyzed_delta, s.baseline_files),
comment_lines_pct_str: fmt_pct(s.comment_lines_delta, s.baseline_comments),
code_lines_added: lines_added,
code_lines_removed: lines_removed,
new_scope: churn.new_scope,
churn_rate_str: churn.churn_rate_str,
churn_rate_class: churn.churn_rate_class,
scope_flag: churn.scope_flag,
files_added: comparison.files_added,
files_removed: comparison.files_removed,
files_modified: comparison.files_modified,
files_unchanged: comparison.files_unchanged,
file_rows,
baseline_git_author: baseline_entry.git_author.clone(),
current_git_author: current_entry.git_author.clone(),
baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
baseline_git_tags: baseline_entry.git_tags.clone(),
current_git_tags: current_entry.git_tags.clone(),
baseline_git_commit_date: baseline_entry
.git_commit_date
.as_deref()
.and_then(fmt_git_date),
current_git_commit_date: current_entry
.git_commit_date
.as_deref()
.and_then(fmt_git_date),
project_name: project_path
.rsplit(['/', '\\'])
.find(|s| !s.is_empty())
.unwrap_or(&project_path)
.to_string(),
submodule_options,
has_any_submodule_data,
active_submodule,
super_scope_active,
csp_nonce,
coverage_delta_card: build_coverage_delta_card(s),
baseline_test_count: effective_baseline.summary_totals.test_count,
current_test_count: effective_current.summary_totals.test_count,
baseline_coverage_pct: s.baseline_coverage_line_pct,
current_coverage_pct: s.current_coverage_line_pct,
};
Html(
template
.render()
.unwrap_or_else(|e| format!("<pre>{e}</pre>")),
)
.into_response()
}
// ── Badge endpoint ────────────────────────────────────────────────────────────
// Returns a shields.io-style SVG badge for embedding in READMEs, Confluence
// pages, Jira descriptions, etc.
//
// GET /badge/<metric>?label=<override>&color=<hex>
// Metrics: code-lines files comment-lines blank-lines
fn format_number(n: u64) -> String {
let s = n.to_string();
let mut out = String::with_capacity(s.len() + s.len() / 3);
let len = s.len();
for (i, c) in s.chars().enumerate() {
if i > 0 && (len - i).is_multiple_of(3) {
out.push(',');
}
out.push(c);
}
out
}
const fn badge_char_width(c: char) -> f64 {
match c {
'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
'm' | 'w' => 9.0,
' ' => 4.0,
_ => 6.5,
}
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
fn badge_text_px(text: &str) -> u32 {
text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
}
fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
let lw = badge_text_px(label) + 20;
let rw = badge_text_px(value) + 20;
let total = lw + rw;
let lx = lw / 2;
let rx = lw + rw / 2;
let le = escape_html(label);
let ve = escape_html(value);
let ce = escape_html(color);
format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
<rect width="{total}" height="20" fill="#555"/>
<rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
<text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
<text x="{lx}" y="13">{le}</text>
<text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
<text x="{rx}" y="13">{ve}</text>
</g>
</svg>"##
)
}
#[derive(Deserialize)]
struct BadgeQuery {
label: Option<String>,
color: Option<String>,
}
async fn badge_handler(
State(state): State<AppState>,
AxumPath(metric): AxumPath<String>,
Query(query): Query<BadgeQuery>,
) -> Response {
let entry = {
let reg = state.registry.lock().await;
reg.entries.first().cloned()
};
let Some(entry) = entry else {
let svg = render_badge_svg("oxide-sloc", "no data", "#999");
return (
[
(header::CONTENT_TYPE, "image/svg+xml"),
(header::CACHE_CONTROL, "no-cache, max-age=0"),
],
svg,
)
.into_response();
};
let (default_label, value, default_color) = match metric.as_str() {
"code-lines" => (
"code lines",
format_number(entry.summary.code_lines),
"#4a78ee",
),
"files" => (
"files analyzed",
format_number(entry.summary.files_analyzed),
"#4a9862",
),
"comment-lines" => (
"comment lines",
format_number(entry.summary.comment_lines),
"#b35428",
),
"blank-lines" => (
"blank lines",
format_number(entry.summary.blank_lines),
"#7a5db0",
),
_ => return StatusCode::NOT_FOUND.into_response(),
};
let label = query.label.as_deref().unwrap_or(default_label);
let color = query.color.as_deref().unwrap_or(default_color);
let svg = render_badge_svg(label, &value, color);
(
[
(header::CONTENT_TYPE, "image/svg+xml"),
(header::CACHE_CONTROL, "no-cache, max-age=0"),
],
svg,
)
.into_response()
}
// ── Metrics API ───────────────────────────────────────────────────────────────
// Protected. Returns a slim JSON payload consumed by Jenkins post-build steps,
// Confluence automation, Jira webhooks, etc.
//
// GET /api/metrics/latest
// GET /api/metrics/<run_id>
#[derive(Serialize)]
struct ApiCoverageBlock {
lines_found: u64,
lines_hit: u64,
line_pct: f64,
functions_found: u64,
functions_hit: u64,
function_pct: f64,
branches_found: u64,
branches_hit: u64,
branch_pct: f64,
}
#[derive(Serialize)]
struct ApiMetricsResponse {
run_id: String,
timestamp: String,
project: String,
summary: ApiSummaryPayload,
languages: Vec<ApiLanguageRow>,
#[serde(skip_serializing_if = "Option::is_none")]
coverage: Option<ApiCoverageBlock>,
}
#[derive(Serialize)]
struct ApiSummaryPayload {
files_analyzed: u64,
files_skipped: u64,
code_lines: u64,
comment_lines: u64,
blank_lines: u64,
total_physical_lines: u64,
functions: u64,
classes: u64,
variables: u64,
imports: u64,
}
#[derive(Serialize)]
struct ApiLanguageRow {
name: String,
files: u64,
code_lines: u64,
comment_lines: u64,
blank_lines: u64,
functions: u64,
classes: u64,
variables: u64,
imports: u64,
}
async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
let entry = {
let reg = state.registry.lock().await;
reg.entries.first().cloned()
};
entry.map_or_else(
|| error::not_found("no scans recorded yet"),
|e| build_metrics_response(&e),
)
}
async fn api_metrics_run_handler(
State(state): State<AppState>,
AxumPath(run_id): AxumPath<String>,
) -> Response {
let entry = {
let reg = state.registry.lock().await;
reg.find_by_run_id(&run_id).cloned()
};
entry.map_or_else(
|| error::not_found("run not found"),
|e| build_metrics_response(&e),
)
}
fn build_metrics_response(entry: &RegistryEntry) -> Response {
let languages: Vec<ApiLanguageRow> = entry
.json_path
.as_ref()
.and_then(|p| read_json(p).ok())
.map(|run| {
run.totals_by_language
.iter()
.map(|l| ApiLanguageRow {
name: l.language.display_name().to_string(),
files: l.files,
code_lines: l.code_lines,
comment_lines: l.comment_lines,
blank_lines: l.blank_lines,
functions: l.functions,
classes: l.classes,
variables: l.variables,
imports: l.imports,
})
.collect()
})
.unwrap_or_default();
let s = &entry.summary;
let coverage = if s.coverage_lines_found > 0 {
let pct = |hit: u64, found: u64| -> f64 {
if found == 0 {
0.0
} else {
#[allow(clippy::cast_precision_loss)]
let v = (hit as f64 / found as f64) * 100.0;
(v * 10.0).round() / 10.0
}
};
Some(ApiCoverageBlock {
lines_found: s.coverage_lines_found,
lines_hit: s.coverage_lines_hit,
line_pct: pct(s.coverage_lines_hit, s.coverage_lines_found),
functions_found: s.coverage_functions_found,
functions_hit: s.coverage_functions_hit,
function_pct: pct(s.coverage_functions_hit, s.coverage_functions_found),
branches_found: s.coverage_branches_found,
branches_hit: s.coverage_branches_hit,
branch_pct: pct(s.coverage_branches_hit, s.coverage_branches_found),
})
} else {
None
};
Json(ApiMetricsResponse {
run_id: entry.run_id.clone(),
timestamp: entry.timestamp_utc.to_rfc3339(),
project: entry.project_label.clone(),
summary: ApiSummaryPayload {
files_analyzed: s.files_analyzed,
files_skipped: s.files_skipped,
code_lines: s.code_lines,
comment_lines: s.comment_lines,
blank_lines: s.blank_lines,
total_physical_lines: s.total_physical_lines,
functions: s.functions,
classes: s.classes,
variables: s.variables,
imports: s.imports,
},
languages,
coverage,
})
.into_response()
}
// ── Project history API ───────────────────────────────────────────────────────
// Protected. Called by the wizard JS when the project path changes, so the UI
// can show a "scanned N times before" badge without a full page reload.
//
// GET /api/project-history?path=<project_root>
#[derive(Deserialize)]
struct ProjectHistoryQuery {
path: Option<String>,
}
#[derive(Serialize)]
struct ProjectHistoryResponse {
scan_count: usize,
last_scan_id: Option<String>,
last_scan_timestamp: Option<String>,
last_scan_code_lines: Option<u64>,
last_git_branch: Option<String>,
last_git_commit: Option<String>,
}
/// Return true if `entry` matches either an exact root path or an upload-staging
/// path with the same project name (needed because each upload gets a fresh UUID dir).
fn entry_matches_project(
entry: &RegistryEntry,
root_str: &str,
upload_root: &str,
upload_name_suffix: Option<&str>,
) -> bool {
if entry.input_roots.iter().any(|r| r == root_str) {
return true;
}
if let Some(suffix) = upload_name_suffix {
return entry
.input_roots
.iter()
.any(|r| r.starts_with(upload_root) && r.ends_with(suffix));
}
false
}
async fn project_history_handler(
State(state): State<AppState>,
Query(query): Query<ProjectHistoryQuery>,
) -> Response {
let path = query.path.unwrap_or_default();
let resolved = resolve_input_path(&path);
let root_str = resolved.to_string_lossy().replace('\\', "/");
// In server mode, uploads land under <tmp>/oxide-sloc-uploads/<uuid>/<project-name>.
// The UUID is freshly generated for every upload, so an exact root_str match never finds
// previous scans of the same project. Fall back to matching by project name within the
// uploads staging directory so Scan History populates correctly across uploads.
let upload_root = std::env::temp_dir()
.join("oxide-sloc-uploads")
.to_string_lossy()
.replace('\\', "/");
let upload_name_suffix: Option<String> =
if state.server_mode && root_str.starts_with(&upload_root) {
resolved
.file_name()
.and_then(|n| n.to_str())
.map(|name| format!("/{name}"))
} else {
None
};
let suffix_ref = upload_name_suffix.as_deref();
let entries: Vec<_> = {
let reg = state.registry.lock().await;
reg.entries
.iter()
.filter(|e| entry_matches_project(e, &root_str, &upload_root, suffix_ref))
.cloned()
.collect()
};
let scan_count = entries.len();
let last = entries.first();
let last_scan_id = last.map(|e| e.run_id.clone());
let last_scan_timestamp = last.map(|e| fmt_la_time(e.timestamp_utc));
let last_scan_code_lines = last.map(|e| e.summary.code_lines);
let last_git_branch = last.and_then(|e| e.git_branch.clone());
let last_git_commit = last.and_then(|e| e.git_commit.clone());
Json(ProjectHistoryResponse {
scan_count,
last_scan_id,
last_scan_timestamp,
last_scan_code_lines,
last_git_branch,
last_git_commit,
})
.into_response()
}
// ── Metrics history API ───────────────────────────────────────────────────────
// Protected. Returns a JSON array of lightweight scan snapshots for plotting
// trend charts.
//
// GET /api/metrics/history?root=<path>&limit=<n>
#[derive(Deserialize)]
struct MetricsHistoryQuery {
root: Option<String>,
limit: Option<usize>,
/// When set, metrics are sourced from the matching `SubmoduleSummary` within each scan's
/// JSON artifact rather than from the project-level `ScanSummarySnapshot`.
submodule: Option<String>,
}
#[derive(Serialize)]
struct MetricsSubmoduleLink {
name: String,
url: String,
}
#[derive(Serialize)]
struct MetricsHistoryEntry {
run_id: String,
run_id_short: String,
timestamp: String,
commit: Option<String>,
branch: Option<String>,
tags: Vec<String>,
nearest_tag: Option<String>,
code_lines: u64,
comment_lines: u64,
blank_lines: u64,
physical_lines: u64,
files_analyzed: u64,
files_skipped: u64,
test_count: u64,
project_label: String,
html_url: Option<String>,
has_pdf: bool,
submodule_links: Vec<MetricsSubmoduleLink>,
/// Line coverage percentage for this scan, or `null` if no coverage data was ingested.
#[serde(skip_serializing_if = "Option::is_none")]
coverage_line_pct: Option<f64>,
}
fn build_entry_submodule_links(e: &sloc_core::history::RegistryEntry) -> Vec<MetricsSubmoduleLink> {
let mut links: Vec<MetricsSubmoduleLink> = vec![];
let sub_dir = e
.html_path
.as_ref()
.and_then(|p| p.parent())
.or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
let Some(dir) = sub_dir else { return links };
let Ok(rd) = std::fs::read_dir(dir) else {
return links;
};
for entry_res in rd.flatten() {
let fname = entry_res.file_name();
let fname_str = fname.to_string_lossy();
if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
let stem = &fname_str[..fname_str.len() - 5];
let display = stem[4..].replace('-', " ");
links.push(MetricsSubmoduleLink {
name: display,
url: format!("/runs/{stem}/{}", e.run_id),
});
}
}
links.sort_by(|a, b| a.name.cmp(&b.name));
links
}
fn apply_submodule_filter(
base: MetricsHistoryEntry,
filter: &str,
e: &sloc_core::history::RegistryEntry,
) -> Option<MetricsHistoryEntry> {
let json_path = e.json_path.as_ref()?;
let json_str = std::fs::read_to_string(json_path).ok()?;
let run: sloc_core::AnalysisRun = serde_json::from_str(&json_str).ok()?;
let sub = run
.submodule_summaries
.iter()
.find(|s| s.name.to_lowercase() == filter || s.relative_path.to_lowercase() == filter)?;
let safe = sanitize_project_label(&sub.name);
let artifact_key = format!("sub_{safe}");
let sub_html_url = std::path::Path::new(json_path).parent().map_or_else(
|| base.html_url.clone(),
|run_dir| {
let sub_path = run_dir.join(format!("{artifact_key}.html"));
if sub_path.exists() {
Some(format!("/runs/{artifact_key}/{}", e.run_id))
} else {
base.html_url.clone()
}
},
);
// Aggregate per-file metrics for this submodule — SubmoduleSummary only stores
// basic SLOC totals, so test_count and coverage must be computed from file records.
let sub_files: Vec<_> = run
.per_file_records
.iter()
.filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
.collect();
let test_count: u64 = sub_files
.iter()
.map(|r| r.raw_line_categories.test_count)
.sum();
#[allow(clippy::cast_precision_loss)]
let coverage_line_pct: Option<f64> = {
let found: u64 = sub_files
.iter()
.filter_map(|r| r.coverage.as_ref())
.map(|c| u64::from(c.lines_found))
.sum();
let hit: u64 = sub_files
.iter()
.filter_map(|r| r.coverage.as_ref())
.map(|c| u64::from(c.lines_hit))
.sum();
if found > 0 {
let pct = (hit as f64 / found as f64) * 100.0;
Some((pct * 10.0).round() / 10.0)
} else {
None
}
};
Some(MetricsHistoryEntry {
code_lines: sub.code_lines,
comment_lines: sub.comment_lines,
blank_lines: sub.blank_lines,
physical_lines: sub.total_physical_lines,
files_analyzed: sub.files_analyzed,
files_skipped: 0,
test_count,
html_url: sub_html_url,
has_pdf: false,
submodule_links: vec![],
coverage_line_pct,
..base
})
}
#[allow(clippy::too_many_lines)] // history aggregation with per-run metric computation and JSON building
async fn api_metrics_history_handler(
State(state): State<AppState>,
Query(query): Query<MetricsHistoryQuery>,
) -> Response {
let limit = query.limit.unwrap_or(50).min(500);
let submodule_filter = query.submodule.as_deref().map(str::to_lowercase);
let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
let reg = state.registry.lock().await;
reg.entries
.iter()
.filter(|e| {
query.root.as_ref().is_none_or(|root| {
let resolved = resolve_input_path(root);
let root_str = resolved.to_string_lossy().replace('\\', "/");
e.input_roots.iter().any(|r| r == &root_str)
})
})
.take(limit)
.cloned()
.collect()
};
let entries: Vec<MetricsHistoryEntry> = candidate_entries
.into_iter()
.filter_map(|e| {
let tags = e
.git_tags
.as_deref()
.map(|s| {
s.split(',')
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.collect()
})
.unwrap_or_default();
let html_url = e
.html_path
.as_ref()
.filter(|p| p.exists())
.map(|_| format!("/runs/html/{}", e.run_id));
let nearest_tag = e.git_nearest_tag.clone();
let has_pdf = e.pdf_path.as_ref().is_some_and(|p| p.exists());
let run_id_short: String = e
.run_id
.split('-')
.next_back()
.unwrap_or(&e.run_id)
.chars()
.take(7)
.collect();
let submodule_links = build_entry_submodule_links(&e);
#[allow(clippy::cast_precision_loss)]
let coverage_line_pct = if e.summary.coverage_lines_found > 0 {
let pct = (e.summary.coverage_lines_hit as f64
/ e.summary.coverage_lines_found as f64)
* 100.0;
Some((pct * 10.0).round() / 10.0)
} else {
None
};
let base = MetricsHistoryEntry {
run_id: e.run_id.clone(),
run_id_short,
timestamp: e.timestamp_utc.to_rfc3339(),
commit: e.git_commit.clone(),
branch: e.git_branch.clone(),
tags,
nearest_tag,
code_lines: e.summary.code_lines,
comment_lines: e.summary.comment_lines,
blank_lines: e.summary.blank_lines,
physical_lines: e.summary.total_physical_lines,
files_analyzed: e.summary.files_analyzed,
files_skipped: e.summary.files_skipped,
test_count: e.summary.test_count,
project_label: e.project_label.clone(),
html_url,
has_pdf,
submodule_links,
coverage_line_pct,
};
if let Some(ref filter) = submodule_filter {
apply_submodule_filter(base, filter, &e)
} else {
Some(base)
}
})
.collect();
Json(entries).into_response()
}
// GET /api/metrics/submodules?root=<path>
// Returns the union of distinct submodule names found across all saved scan JSON artifacts
// for the given project root (or all roots if omitted).
#[derive(Deserialize)]
struct MetricsSubmodulesQuery {
root: Option<String>,
}
#[derive(Serialize)]
struct SubmoduleEntry {
name: String,
relative_path: String,
}
async fn api_metrics_submodules_handler(
State(state): State<AppState>,
Query(query): Query<MetricsSubmodulesQuery>,
) -> Response {
let json_paths: Vec<std::path::PathBuf> = {
let reg = state.registry.lock().await;
reg.entries
.iter()
.filter(|e| {
query.root.as_ref().is_none_or(|root| {
let resolved = resolve_input_path(root);
let root_str = resolved.to_string_lossy().replace('\\', "/");
e.input_roots.iter().any(|r| r == &root_str)
})
})
.filter_map(|e| e.json_path.clone())
.collect()
};
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut result: Vec<SubmoduleEntry> = Vec::new();
for path in &json_paths {
let Ok(json_str) = tokio::fs::read_to_string(path).await else {
continue;
};
let Ok(run): Result<sloc_core::AnalysisRun, _> = serde_json::from_str(&json_str) else {
continue;
};
for sub in &run.submodule_summaries {
if seen.insert(sub.name.clone()) {
result.push(SubmoduleEntry {
name: sub.name.clone(),
relative_path: sub.relative_path.clone(),
});
}
}
}
result.sort_by(|a, b| a.name.cmp(&b.name));
Json(result).into_response()
}
// ── CI ingest endpoint ────────────────────────────────────────────────────────
// Protected. Accepts a pre-computed AnalysisRun JSON posted by a CI job so the
// server stores and displays results without cloning or scanning anything itself.
//
// POST /api/ingest?label=<optional_display_name>
// Body: AnalysisRun JSON produced by `oxide-sloc analyze --json-out`
// Send: `oxide-sloc send result.json --webhook-url <server>/api/ingest [--webhook-token <key>]`
#[derive(Deserialize)]
struct IngestQuery {
label: Option<String>,
}
#[derive(Serialize)]
struct IngestResponse {
run_id: String,
view_url: String,
}
async fn api_ingest_handler(
State(state): State<AppState>,
Query(q): Query<IngestQuery>,
Json(run): Json<sloc_core::AnalysisRun>,
) -> Response {
let label = q.label.unwrap_or_else(|| {
run.input_roots
.first()
.map_or_else(|| "ingested".to_owned(), |r| sanitize_project_label(r))
});
let label_for_task = label.clone();
let result = tokio::task::spawn_blocking(move || {
let html = render_html(&run)?;
let run_id = run.tool.run_id.clone();
let run_id_safe = run_id.len() <= 128
&& !run_id.is_empty()
&& run_id
.chars()
.all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'));
if !run_id_safe {
anyhow::bail!(
"invalid run_id: must be 1-128 alphanumeric/dash/underscore/dot characters"
);
}
let project_label = sanitize_project_label(&label_for_task);
let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
let file_stem = match run.git_commit_short.as_deref().map(str::trim) {
Some(c) if !c.is_empty() => format!("{project_label}_{c}"),
_ => project_label,
};
let (artifacts, _pending_pdf) = persist_run_artifacts(
&run,
&html,
&output_dir,
&label_for_task,
&file_stem,
RunResultContext::default(),
)?;
Ok::<_, anyhow::Error>((run_id, artifacts, run))
})
.await;
match result {
Ok(Ok((run_id, artifacts, run))) => {
register_artifacts_in_registry(&state, &label, &run, &artifacts).await;
(
StatusCode::CREATED,
Json(IngestResponse {
view_url: format!("/view-reports?run_id={run_id}"),
run_id,
}),
)
.into_response()
}
Ok(Err(e)) => error::internal(&format!("{e:#}")),
Err(e) => error::internal(&format!("{e}")),
}
}
// ── Multi-compare page ────────────────────────────────────────────────────────
// GET /multi-compare?runs=id1,id2,id3,...
fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
#[allow(clippy::cast_precision_loss)]
fn fmt_num(n: i64) -> String {
let a = n.unsigned_abs();
if a >= 1_000_000 {
let v = n as f64 / 1_000_000.0;
let s = format!("{v:.1}");
format!("{}M", s.trim_end_matches(".0"))
} else if a >= 10_000 {
let v = n as f64 / 1_000.0;
let s = format!("{v:.1}");
format!("{}K", s.trim_end_matches(".0"))
} else {
let sign = if n < 0 { "-" } else { "" };
if a < 1_000 {
return format!("{sign}{a}");
}
format!("{sign}{},{:03}", a / 1_000, a % 1_000)
}
}
fn fmt_comma(n: i64) -> String {
let sign = if n < 0 { "-" } else { "" };
let a = n.unsigned_abs();
if a < 1_000 {
return format!("{sign}{a}");
}
let s = a.to_string();
let bytes = s.as_bytes();
let len = bytes.len();
let mut out = String::with_capacity(len + len / 3);
for (i, &b) in bytes.iter().enumerate() {
if i > 0 && (len - i).is_multiple_of(3) {
out.push(',');
}
out.push(b as char);
}
format!("{sign}{out}")
}
#[derive(Deserialize, Default)]
struct MultiCompareQuery {
runs: Option<String>,
/// "super" to show only super-repo files (exclude all submodule files)
scope: Option<String>,
/// Submodule name to narrow the comparison to one submodule
sub: Option<String>,
}
#[allow(clippy::too_many_lines)]
async fn multi_compare_handler(
State(state): State<AppState>,
Query(params): Query<MultiCompareQuery>,
axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
) -> impl IntoResponse {
let run_ids: Vec<String> = params
.runs
.as_deref()
.unwrap_or("")
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if run_ids.len() < 2 {
return Html(
"<p style='font-family:sans-serif;padding:2rem'>At least 2 run IDs are required. \
<a href=\"/compare-scans\">Go back</a></p>",
)
.into_response();
}
if run_ids.len() > 20 {
return Html(
"<p style='font-family:sans-serif;padding:2rem'>At most 20 scans can be compared \
at once. <a href=\"/compare-scans\">Go back</a></p>",
)
.into_response();
}
// Look up each run_id in the registry.
let entries: Vec<Option<RegistryEntry>> = {
let reg = state.registry.lock().await;
run_ids
.iter()
.map(|id| reg.entries.iter().find(|e| &e.run_id == id).cloned())
.collect()
};
for (i, entry) in entries.iter().enumerate() {
if entry.is_none() {
let html = format!(
"<p style='font-family:sans-serif;padding:2rem'>Scan ID <code>{}</code> not \
found. <a href=\"/compare-scans\">Go back</a></p>",
run_ids[i]
);
return Html(html).into_response();
}
}
let mut entries: Vec<RegistryEntry> = entries.into_iter().flatten().collect();
for entry in &entries {
if entry.json_path.is_none() {
let html = format!(
"<p style='font-family:sans-serif;padding:2rem'>Scan <code>{}</code> has no \
JSON data — re-run the analysis to enable comparison. \
<a href=\"/compare-scans\">Go back</a></p>",
&entry.run_id
);
return Html(html).into_response();
}
}
// Sort chronologically.
entries.sort_by_key(|e| e.timestamp_utc);
// Load JSON for each entry.
let mut runs: Vec<AnalysisRun> = Vec::with_capacity(entries.len());
for entry in &entries {
let path = entry.json_path.as_ref().unwrap();
match read_json(path) {
Ok(r) => runs.push(r),
Err(e) => {
let html = format!(
"<p style='font-family:sans-serif;padding:2rem'>Could not load scan \
<code>{}</code>: {e}. <a href=\"/compare-scans\">Go back</a></p>",
&entry.run_id
);
return Html(html).into_response();
}
}
}
// Collect submodule names from all runs.
let all_sub_names: Vec<String> = {
let mut set = std::collections::BTreeSet::new();
for r in &runs {
for s in &r.submodule_summaries {
set.insert(s.name.clone());
}
}
set.into_iter().collect()
};
let has_submodule_data = !all_sub_names.is_empty();
let active_submodule = params.sub.clone();
let super_scope_active = params.scope.as_deref() == Some("super");
// Narrow per_file_records when a scope is active, then recompute totals.
apply_scope_filter(&mut runs, &active_submodule, super_scope_active);
let runs_csv = params.runs.as_deref().unwrap_or("").to_string();
let project_label = entries
.first()
.map_or("", |e| e.project_label.as_str())
.to_string();
let run_refs: Vec<&AnalysisRun> = runs.iter().collect();
let multi = compute_multi_delta(&run_refs);
let html = multi_compare_page(
&multi,
&project_label,
env!("CARGO_PKG_VERSION"),
&csp_nonce,
has_submodule_data,
&all_sub_names,
&runs_csv,
super_scope_active,
active_submodule.as_deref(),
&entries,
);
// no-store: this page is regenerated on every request and embeds inline JS; a cached
// copy after a rebuild would silently mask UI fixes.
(
[(axum::http::header::CACHE_CONTROL, "no-store")],
Html(html),
)
.into_response()
}
const fn multi_delta_class(n: i64) -> &'static str {
match n {
1.. => "pos",
..=-1 => "neg",
0 => "zero",
}
}
fn multi_fmt_delta(n: i64) -> String {
if n > 0 {
format!("+{n}")
} else {
format!("{n}")
}
}
/// Escape a string for safe embedding inside a JSON/JS string literal (no allocation if clean).
fn js_escape(s: &str) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(s.len() + 2);
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => {
let _ = write!(out, "\\u{:04x}", c as u32);
}
c => out.push(c),
}
}
out
}
/// Retrieve commit-date and author HTML strings from the registry entry at `(idx, run_id)`.
fn mc_entry_html_data(entries: &[RegistryEntry], idx: usize, run_id: &str) -> (String, String) {
let Some(entry) = entries.get(idx).filter(|e| e.run_id == run_id) else {
return (
"—".to_string(),
"<span class=\"mc-row-val\">—</span>".to_string(),
);
};
let cd = entry
.git_commit_date
.as_deref()
.and_then(fmt_git_date)
.unwrap_or_else(|| "—".to_string());
let au = entry.git_author.as_deref().map_or_else(
|| "<span class=\"mc-row-val\">—</span>".to_string(),
|a| {
format!(
"<span class=\"mc-row-val\"><span class=\"cmp-author-val\">{}</span>\
<span class=\"cmp-author-handle\"></span></span>",
html_escape(a)
)
},
);
(cd, au)
}
/// Render the scope badge chip for a scan card header.
fn mc_scope_badge(active_sub: Option<&str>, super_scope_active: bool) -> String {
active_sub.map_or_else(
|| {
if super_scope_active {
"<span class=\"mc-scope-tag mc-scope-super\">Super-repo only</span>".to_string()
} else {
"<span class=\"mc-scope-tag mc-scope-full\">\
<svg width=\"9\" height=\"9\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\">\
<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>"
.to_string()
}
},
|s| format!("<span class=\"mc-scope-tag mc-scope-sub\">{}</span>", html_escape(s)),
)
}
/// Build the HTML for the horizontal strip of scan cards (with arrows between them).
fn build_mc_scan_strip(
multi: &MultiScanComparison,
entries: &[RegistryEntry],
n: usize,
is_many: bool,
active_sub: Option<&str>,
super_scope_active: bool,
project_label: &str,
) -> String {
use std::fmt::Write as _;
let mut scan_strip = String::new();
for (i, pt) in multi.points.iter().enumerate() {
let ts_ms = pt.timestamp.timestamp_millis();
let ts = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
let commit = pt.git_commit.as_deref().unwrap_or("\u{2014}");
let branch = pt.git_branch.as_deref().unwrap_or("");
let report_link = format!("/runs/html/{}", pt.run_id);
let branch_html = if branch.is_empty() {
"<span class=\"mc-row-val\">—</span>".to_string()
} else {
format!(
"<span class=\"mc-card-branch\">{}</span>",
html_escape(branch)
)
};
let (commit_date_html, author_html) = mc_entry_html_data(entries, i, &pt.run_id);
let tags_html = pt
.git_tags
.as_deref()
.filter(|t| !t.is_empty())
.map(|t| {
let chips = t
.split(',')
.filter(|s| !s.is_empty())
.map(|tag| format!("<span class='mc-tag'>{}</span>", html_escape(tag)))
.collect::<Vec<_>>()
.join(" ");
format!(
"<div class=\"mc-card-row\"><span class=\"mc-row-label\">Tags:</span>\
<span class=\"mc-row-val\">{chips}</span></div>"
)
})
.unwrap_or_default();
let nearest = pt
.git_nearest_tag
.as_deref()
.map(|t| format!("near {}", html_escape(t)))
.unwrap_or_default();
let arrow = if i < n - 1 && !is_many {
"<div class='mc-arrow'>→</div>"
} else {
""
};
let scope_badge = mc_scope_badge(active_sub, super_scope_active);
let nearest_html = if nearest.is_empty() {
String::new()
} else {
format!(
"<span class=\"mc-card-nearest-wrap\">\
<span class=\"mc-card-nearest\">{nearest}</span>\
<span class=\"mc-card-nearest-tip\">Nearest ancestor git release tag at scan time</span>\
</span>"
)
};
write!(
scan_strip,
r#"<div class="mc-card">
<div class="mc-card-header">
<div class="mc-card-num">Scan {num}</div>
<div class="mc-card-project-col">
<div class="mc-card-project">{project_label}</div>
{scope_badge}
</div>
</div>
<a class="mc-card-commit" href="{report_link}" target="_blank" title="View report">{commit}</a>
<div class="mc-card-rows">
<div class="mc-card-row"><span class="mc-row-label">Branch:</span>{branch_html}</div>
<div class="mc-card-row"><span class="mc-row-label">Last commit on:</span><span class="mc-row-val">{commit_date}</span></div>
<div class="mc-card-row"><span class="mc-row-label">Last commit by:</span>{author_html}</div>
<div class="mc-card-row"><span class="mc-row-label">Scanned on:</span><span class="mc-row-val mc-ts-local" data-utc-ms="{ts_ms}">{ts}</span></div>
{tags_html}
</div>
<div class="mc-card-code"><strong>{code} loc</strong>{nearest_html}</div>
</div>{arrow}"#,
num = i + 1,
commit = html_escape(commit),
commit_date = commit_date_html,
ts_ms = ts_ms,
code = fmt_num(pt.code_lines),
scope_badge = scope_badge,
nearest_html = nearest_html,
)
.unwrap();
}
scan_strip
}
/// Build the metric progression table (thead + tbody) for multi-compare.
#[allow(clippy::too_many_lines)]
fn build_mc_metrics_table(multi: &MultiScanComparison, n: usize) -> (String, String) {
use std::fmt::Write as _;
struct MetricRow<'a> {
label: &'a str,
values: Vec<i64>,
seq_deltas: Vec<i64>,
net_delta: i64,
}
let rows: Vec<MetricRow<'_>> = vec![
MetricRow {
label: "Code Lines",
values: multi.points.iter().map(|p| p.code_lines).collect(),
seq_deltas: multi
.sequential_deltas
.iter()
.map(|d| d.summary.code_lines_delta)
.collect(),
net_delta: multi.total_delta.code_lines_delta,
},
MetricRow {
label: "Files Analyzed",
values: multi.points.iter().map(|p| p.files_analyzed).collect(),
seq_deltas: multi
.sequential_deltas
.iter()
.map(|d| d.summary.files_analyzed_delta)
.collect(),
net_delta: multi.total_delta.files_analyzed_delta,
},
MetricRow {
label: "Comment Lines",
values: multi.points.iter().map(|p| p.comment_lines).collect(),
seq_deltas: multi
.sequential_deltas
.iter()
.map(|d| d.summary.comment_lines_delta)
.collect(),
net_delta: multi.total_delta.comment_lines_delta,
},
MetricRow {
label: "Blank Lines",
values: multi.points.iter().map(|p| p.blank_lines).collect(),
seq_deltas: multi
.sequential_deltas
.iter()
.map(|d| d.summary.blank_lines_delta)
.collect(),
net_delta: multi.total_delta.blank_lines_delta,
},
MetricRow {
label: "Tests",
values: multi.points.iter().map(|p| p.test_count).collect(),
seq_deltas: multi
.points
.windows(2)
.map(|pts| pts[1].test_count - pts[0].test_count)
.collect(),
net_delta: multi.points.last().map_or(0, |l| l.test_count)
- multi.points.first().map_or(0, |f| f.test_count),
},
];
let mut metrics_thead = String::from("<tr><th class='mc-met-label'>Metric</th>");
for i in 0..n {
write!(metrics_thead, "<th class='mc-val-col'>Scan {}</th>", i + 1).unwrap();
if i < n - 1 {
metrics_thead.push_str("<th class='mc-delta-col'>→Δ</th>");
}
}
metrics_thead.push_str("<th class='mc-net-col'>Net Δ</th></tr>");
let mut metrics_tbody = String::new();
for row in &rows {
metrics_tbody.push_str("<tr>");
write!(metrics_tbody, "<td class='mc-met-label'>{}</td>", row.label).unwrap();
for i in 0..n {
write!(
metrics_tbody,
"<td class='mc-val-col'>{}</td>",
fmt_comma(row.values[i])
)
.unwrap();
if i < n - 1 {
let d = row.seq_deltas[i];
write!(
metrics_tbody,
"<td class='mc-delta-col {cls}'>{val}</td>",
cls = multi_delta_class(d),
val = multi_fmt_delta(d)
)
.unwrap();
}
}
let nd = row.net_delta;
write!(
metrics_tbody,
"<td class='mc-net-col {cls}'>{val}</td>",
cls = multi_delta_class(nd),
val = multi_fmt_delta(nd)
)
.unwrap();
metrics_tbody.push_str("</tr>");
}
(metrics_thead, metrics_tbody)
}
/// Build the JS-embeddable points JSON array for the multi-compare chart.
fn build_mc_points_json(multi: &MultiScanComparison, entries: &[RegistryEntry]) -> String {
let mut parts: Vec<String> = Vec::with_capacity(multi.points.len());
for (i, pt) in multi.points.iter().enumerate() {
let commit = pt.git_commit.as_deref().unwrap_or("");
let branch = pt.git_branch.as_deref().unwrap_or("");
let tags = pt.git_tags.as_deref().unwrap_or("");
let nearest = pt.git_nearest_tag.as_deref().unwrap_or("");
let scanned_ms = pt.timestamp.timestamp_millis();
let scanned = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
let entry = entries.get(i).filter(|e| e.run_id == pt.run_id);
let commit_date = entry
.and_then(|e| e.git_commit_date.as_deref())
.and_then(fmt_git_date)
.unwrap_or_default();
let author = entry
.and_then(|e| e.git_author.as_deref())
.unwrap_or("")
.to_string();
let cov = pt
.coverage_line_pct
.map_or_else(|| "null".to_string(), |v| format!("{v:.1}"));
parts.push(format!(
r#"{{"run_id":"{run_id}","commit":"{commit}","branch":"{branch}","tags":"{tags}","nearest":"{nearest}","commit_date":"{commit_date}","author":"{author}","scanned":"{scanned}","scanned_ms":{scanned_ms},"code":{code},"comments":{comments},"blank":{blank},"files":{files},"tests":{tests},"cov":{cov}}}"#,
run_id = js_escape(&pt.run_id),
commit = js_escape(commit),
branch = js_escape(branch),
tags = js_escape(tags),
nearest = js_escape(nearest),
commit_date = js_escape(&commit_date),
author = js_escape(&author),
scanned = js_escape(&scanned),
code = pt.code_lines,
comments = pt.comment_lines,
blank = pt.blank_lines,
files = pt.files_analyzed,
tests = pt.test_count,
));
}
format!("[{}]", parts.join(","))
}
/// Build the JS-embeddable file-matrix JSON array for the multi-compare table.
fn build_mc_file_matrix_json(multi: &MultiScanComparison) -> String {
let mut parts: Vec<String> = Vec::with_capacity(multi.file_matrix.len());
for row in &multi.file_matrix {
let lang = row.language.as_deref().unwrap_or("");
let codes: Vec<String> = row
.code_per_scan
.iter()
.map(|v| v.map_or("null".to_string(), |x| x.to_string()))
.collect();
let deltas: Vec<String> = row
.code_delta_per_scan
.iter()
.map(|v| v.map_or("null".to_string(), |x| x.to_string()))
.collect();
parts.push(format!(
r#"{{"p":"{path}","l":"{lang}","s":"{status}","c":[{codes}],"d":[{deltas}],"t":{total}}}"#,
path = row.relative_path.replace('\\', "/").replace('"', "\\\""),
status = row.overall_status,
codes = codes.join(","),
deltas = deltas.join(","),
total = row.total_code_delta,
));
}
format!("[{}]", parts.join(","))
}
/// Build the column header cells for the file-matrix table.
fn build_mc_file_col_headers(n: usize) -> String {
use std::fmt::Write as _;
let mut out = String::new();
for i in 0..n {
write!(out, "<th class='file-scan-col'>Scan {} Code</th>", i + 1).unwrap();
if i < n - 1 {
write!(
out,
"<th class='file-delta-col'>Δ→{}</th>",
i + 2
)
.unwrap();
}
}
out
}
/// Build the submodule scope-selector bar HTML (empty string when no submodule data).
fn build_mc_scope_bar(
has_submodule_data: bool,
sub_names: &[String],
runs_csv: &str,
active_sub: Option<&str>,
super_scope_active: bool,
) -> String {
use std::fmt::Write as _;
if !has_submodule_data {
return String::new();
}
let base_url = format!("/multi-compare?runs={}", html_escape(runs_csv));
let full_active = active_sub.is_none() && !super_scope_active;
let mut bar = format!(
r#"<div class="submod-scope-bar">
<span class="submod-scope-label">
<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>
Scope:
</span>
<div class="submod-scope-divider"></div>
<a class="submod-scope-btn{full_cls}" href="{base_url}" title="All files — super-repo and all submodules combined">Full scan</a>
<a class="submod-scope-btn{super_cls}" href="{base_url}&scope=super" title="Only files not belonging to any submodule">Super-repo only</a>"#,
full_cls = if full_active { " active" } else { "" },
super_cls = if super_scope_active { " active" } else { "" },
);
for s in sub_names {
let is_active = active_sub == Some(s.as_str());
write!(
bar,
"\n <a class=\"submod-scope-btn{cls}\" href=\"{base_url}&sub={name_enc}\" title=\"Only files in submodule {name_esc}\">{name_esc}</a>",
cls = if is_active { " active" } else { "" },
name_enc = html_escape(s),
name_esc = html_escape(s),
)
.unwrap();
}
bar.push_str("\n</div>");
bar
}
/// Build the scope-description label shown in the page subtitle.
fn build_mc_scope_label(active_sub: Option<&str>, super_scope_active: bool) -> String {
active_sub.map_or_else(
|| {
if super_scope_active {
"Super-repo only — ".to_string()
} else {
String::new()
}
},
|s| format!("Submodule: {} — ", html_escape(s)),
)
}
#[allow(clippy::too_many_lines)]
#[allow(clippy::too_many_arguments)]
fn multi_compare_page(
multi: &MultiScanComparison,
project_label: &str,
version: &str,
csp_nonce: &str,
has_submodule_data: bool,
sub_names: &[String],
runs_csv: &str,
super_scope_active: bool,
active_sub: Option<&str>,
entries: &[RegistryEntry],
) -> String {
let n = multi.points.len();
let is_many = n > 4;
let mc_strip_class = if is_many {
"mc-strip mc-strip-grid"
} else {
"mc-strip"
};
// ── Scan strip cards ──────────────────────────────────────────────────────
let scan_strip = build_mc_scan_strip(
multi,
entries,
n,
is_many,
active_sub,
super_scope_active,
project_label,
);
// ── Summary metrics table ─────────────────────────────────────────────────
let (metrics_thead, metrics_tbody) = build_mc_metrics_table(multi, n);
// ── Chart data and table helpers ──────────────────────────────────────────
let points_json = build_mc_points_json(multi, entries);
let file_matrix_json = build_mc_file_matrix_json(multi);
// Counts for filter tabs
let files_modified = multi
.file_matrix
.iter()
.filter(|f| f.overall_status == "modified")
.count();
let files_added = multi
.file_matrix
.iter()
.filter(|f| f.overall_status == "added")
.count();
let files_removed = multi
.file_matrix
.iter()
.filter(|f| f.overall_status == "removed")
.count();
let files_unchanged = multi
.file_matrix
.iter()
.filter(|f| f.overall_status == "unchanged")
.count();
let total_files = multi.file_matrix.len();
let file_col_headers = build_mc_file_col_headers(n);
let nav_compare_active = "";
let scope_bar_html = build_mc_scope_bar(
has_submodule_data,
sub_names,
runs_csv,
active_sub,
super_scope_active,
);
let scope_label = build_mc_scope_label(active_sub, super_scope_active);
format!(
r##"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OxideSLOC | Multi-Scan Timeline — {project_label}</title>
<link rel="icon" type="image/png" href="/images/logo/small-logo.png">
<style nonce="{csp_nonce}">
:root{{--radius:18px;--bg:#f5efe8;--surface:#fbf7f2;--surface-2:#f4ede4;--line:#e6d0bf;--line-strong:#d8bfad;--text:#43342d;--muted:#7b675b;--muted-2:#a08777;--nav:#283790;--nav-2:#013e6b;--accent:#6f9bff;--oxide:#d37a4c;--oxide-2:#b35428;--shadow:0 18px 42px rgba(77,44,20,0.12);--pos:#1a8f47;--pos-bg:#e8f5ed;--neg:#b33b3b;--neg-bg:#fcd6d6;}}
*,*::before,*::after{{box-sizing:border-box;margin:0;padding:0;}}
body{{background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,sans-serif;min-height:100vh;}}
body.dark-theme{{--bg:#1a120b;--surface:#241a12;--surface-2:#2d2117;--line:#3d2e22;--line-strong:#54402f;--text:#f0e6dc;--muted:#b09080;--muted-2:#8a6e5f;--pos-bg:#163a23;--neg-bg:#3d1c1c;}}
.background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
.background-watermarks img{{position:absolute;opacity:0.15;filter:blur(0.3px);user-select:none;max-width:none;}}
.code-particles{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
.code-particle{{position:absolute;font-family:ui-monospace,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}}
@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));}}}}
.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);}}
.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;}}
@media(max-width:1920px){{.top-nav-inner{{max-width:1500px;}}.page{{max-width:1500px;}}}}
@media(max-width:1400px){{.nav-right{{gap:6px;}}.nav-pill,.nav-dropdown-btn,.theme-toggle{{padding:0 10px;}}}}
@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;}}}}
.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));}}
.brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
.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;}}
.nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}}
.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;}}
.nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
.theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}}
.theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
.theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
.nav-dropdown{{position:relative;display:inline-flex;}}
.nav-dropdown-btn{{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;cursor:pointer;transition:background .15s ease,transform .15s ease;}}
.nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
.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 .13s,visibility 0s .13s;}}
.nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{{opacity:1;visibility:visible;transition:opacity .13s,visibility 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;}}
body:not(.dark-theme) .icon-sun{{display:none;}}
body.dark-theme .icon-moon{{display:none;}}
.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;}}
.settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
.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);}}
.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;}}
.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;}}
.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;}}
.scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
.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;}}
.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);}}
.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;}}
.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;}}
.page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
.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;white-space:nowrap;margin-bottom:16px;}}
.btn-back:hover{{background:var(--line);}}
.mc-title{{font-size:28px;font-weight:900;letter-spacing:-.03em;margin:0 0 6px;background:linear-gradient(90deg,#b85d33 0%,#d37a4c 40%,#6f9bff 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;}}
body.dark-theme .mc-title{{background:linear-gradient(90deg,#f0a070 0%,#d37a4c 40%,#9bb8ff 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;}}
.mc-desc{{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}}
.mc-subtitle{{font-size:14px;color:var(--muted);margin:0 0 6px;}}
.mc-strip{{display:flex;align-items:stretch;flex-wrap:wrap;gap:12px;overflow:visible;padding:8px 4px 6px;margin-bottom:20px;width:100%;}}
.mc-strip.mc-strip-grid{{display:grid!important;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:14px;overflow:visible;padding:8px 4px 6px;}}
.mc-hero{{background:linear-gradient(180deg,rgba(255,255,255,0.18),transparent),var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px 24px 24px;margin-bottom:18px;}}
.mc-hero-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:16px;flex-wrap:wrap;}}
.mc-card{{background:var(--surface);border:1.5px solid var(--oxide);border-radius:14px;padding:16px 18px;flex:1 1 0;min-width:0;min-height:160px;display:flex;flex-direction:column;justify-content:flex-start;transition:box-shadow .15s ease,transform .12s ease;overflow:visible;position:relative;}}
.mc-card:hover{{box-shadow:0 10px 28px rgba(77,44,20,0.18);}}
body.dark-theme .mc-card{{background:var(--surface-2);}}
.mc-card-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:10px;}}
.mc-card-num{{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);}}
.mc-card-project{{font-size:12px;font-weight:600;color:var(--muted);font-style:italic;text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%;}}
.mc-card-commit{{display:block;font-family:ui-monospace,monospace;font-size:24px;font-weight:800;letter-spacing:-0.02em;line-height:1.1;color:var(--accent);text-decoration:none;margin-bottom:14px;word-break:break-all;}}
.mc-card-commit:hover{{color:var(--oxide);}}
.mc-card-rows{{display:flex;flex-direction:column;gap:6px;}}
.mc-card-row{{display:flex;align-items:baseline;gap:8px;font-size:13px;}}
.mc-row-label{{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}}
.mc-row-val{{color:var(--text);font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;}}
.mc-card-branch{{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);font-weight:700;display:inline-block;}}
.mc-tag{{font-size:10px;background:rgba(211,122,76,0.12);border:1px solid rgba(211,122,76,0.28);border-radius:4px;padding:1px 6px;color:var(--oxide);font-weight:700;margin-right:3px;display:inline-block;}}
.mc-card-project-col{{display:flex;flex-direction:column;align-items:flex-end;gap:5px;max-width:72%;}}
.mc-scope-tag{{display:inline-flex;align-items:center;gap:4px;font-size:10px;font-weight:800;padding:2px 8px;border-radius:5px;white-space:nowrap;letter-spacing:.03em;text-transform:uppercase;}}
.mc-scope-full{{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}}
.mc-scope-sub{{background:rgba(111,155,255,0.10);border:1px solid rgba(111,155,255,0.28);color:var(--accent);}}
.mc-scope-super{{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.28);color:var(--oxide);}}
.mc-card-nearest-wrap{{position:relative;display:inline-flex;align-items:center;gap:4px;cursor:default;}}
.mc-card-nearest{{font-size:10px;color:var(--muted-2);font-style:italic;}}
.mc-card-nearest-tip{{display:none;position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);background:rgba(20,12,8,0.97);color:rgba(255,255,255,0.92);border-radius:8px;padding:6px 10px;font-size:11px;font-weight:500;line-height:1.5;white-space:nowrap;box-shadow:0 4px 12px rgba(0,0,0,0.28);pointer-events:none;z-index:200;border:1px solid rgba(255,255,255,0.10);}}
.mc-card-nearest-tip::after{{content:'';position:absolute;top:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-top-color:rgba(20,12,8,0.97);}}
.mc-card-nearest-wrap:hover .mc-card-nearest-tip{{display:block;}}
.mc-card-code{{font-size:15px;font-weight:800;color:var(--text);margin-top:12px;padding-top:10px;border-top:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;gap:6px;flex-wrap:nowrap;}}
.cmp-author-handle{{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}}
.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:0 0 16px;}}
.submod-scope-divider{{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}}
.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;}}
.submod-scope-label svg{{stroke:currentColor;fill:none;stroke-width:2;}}
.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,border-color .12s,color .12s;}}
.submod-scope-btn:hover{{background:var(--line);}}
.submod-scope-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
.mc-arrow{{font-size:22px;color:var(--muted);align-self:center;padding:0 4px;flex-shrink:0;}}
.panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px 24px;margin-bottom:18px;position:relative;}}
.panel-title{{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}}
.metrics-table{{width:100%;border-collapse:collapse;font-size:13px;}}
.metrics-table th,.metrics-table td{{padding:9px 12px;border-bottom:1px solid var(--line);text-align:right;}}
.metrics-table th{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);background:var(--surface-2);}}
.metrics-table td.mc-met-label,.metrics-table th.mc-met-label{{text-align:left;font-weight:700;color:var(--text);}}
.metrics-table .mc-val-col{{font-weight:700;font-variant-numeric:tabular-nums;}}
.metrics-table .mc-delta-col{{font-size:12px;font-weight:700;font-variant-numeric:tabular-nums;}}
.metrics-table .mc-net-col{{font-weight:800;font-size:13px;font-variant-numeric:tabular-nums;background:rgba(111,155,255,0.06);}}
.metrics-table .pos{{color:var(--pos);}}
.metrics-table .neg{{color:var(--neg);}}
.metrics-table .zero{{color:var(--muted);}}
.metrics-table tr:hover td{{background:rgba(211,122,76,0.04);}}
.chart-toolbar{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
.chart-metric-btn{{padding:5px 13px;border-radius:7px;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:12px;font-weight:700;cursor:pointer;transition:background .12s;}}
.chart-metric-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
.chart-metric-btn:hover:not(.active){{background:var(--line);}}
.chart-wrap{{width:100%;overflow-x:auto;}}
#mc-chart{{display:block;width:100%;}}
h2,.mc-charts-h2{{font-size:14px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 14px;}}
.export-group{{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:4px;}}
.ic-grid{{display:grid;grid-template-columns:1fr 1fr;gap:16px;}}
@media(max-width:800px){{.ic-grid{{grid-template-columns:1fr;}}}}
.ic-card{{background:var(--surface-2);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}}
body.dark-theme .ic-card{{border-color:var(--line-strong);}}
.ic-card-h2{{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}}
.ic-card-h2-row{{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;}}
.ic-card-h2-row .ic-card-h2{{margin:0;}}
.ic-leg{{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}}
.ic-dot{{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}}
.ic-cb{{cursor:pointer;transition:filter .15s;}}
.ic-cb:hover{{filter:brightness(1.12);}}
.ic-leg-item{{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}}
.ic-leg-item:hover{{background:rgba(211,122,76,0.08);}}
#mc-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;}}
.filter-tabs-row{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
.delta-note{{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}}
.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;}}
.tab-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
.tab-btn:hover:not(.active){{background:var(--line);}}
.tab-btn.tab-modified{{background:#fff2d8;color:#926000;border-color:#e6c96c;}}
.tab-btn.tab-modified.active{{background:#926000;border-color:#926000;color:#fff;}}
.tab-btn.tab-added{{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}}
.tab-btn.tab-added.active{{background:#1a8f47;border-color:#1a8f47;color:#fff;}}
.tab-btn.tab-removed{{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}}
.tab-btn.tab-removed.active{{background:#b33b3b;border-color:#b33b3b;color:#fff;}}
body.dark-theme .tab-btn.tab-modified{{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}}
body.dark-theme .tab-btn.tab-added{{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}}
body.dark-theme .tab-btn.tab-removed{{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}}
.table-wrap{{width:100%;overflow-x:auto;}}
#file-table{{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}}
#file-table th,#file-table td{{padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap;}}
#file-table th{{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);background:var(--surface-2);text-align:right;}}
#file-table th.left,#file-table td.left{{text-align:left;}}
.file-scan-col,.file-delta-col,.file-net-col{{text-align:right;font-variant-numeric:tabular-nums;font-weight:600;}}
.file-delta-col{{color:var(--muted);font-size:11px;}}
.file-net-col{{font-weight:800;}}
.pos{{color:var(--pos);}} .neg{{color:var(--neg);}} .zero{{color:var(--muted);}}
#file-table th.sortable{{cursor:pointer;user-select:none;}} #file-table th.sortable:hover{{color:var(--oxide);}}
#file-table .sort-icon{{margin-left:3px;font-size:9px;opacity:.4;vertical-align:middle;}}
#file-table th.sort-asc .sort-icon,#file-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
.status-badge{{padding:2px 7px;border-radius:4px;font-size:10px;font-weight:700;text-transform:uppercase;}}
.status-badge.modified{{background:#fff2d8;color:#926000;}}
.status-badge.added{{background:#e8f5ed;color:#1a8f47;}}
.status-badge.removed{{background:#fdeaea;color:#b33b3b;}}
.status-badge.unchanged{{background:var(--surface-2);color:var(--muted);}}
body.dark-theme .status-badge.modified{{background:#3d2f0a;color:#f0c060;}}
body.dark-theme .status-badge.added{{background:#163927;color:#8fe2a8;}}
body.dark-theme .status-badge.removed{{background:#3d1c1c;color:#f5a3a3;}}
tr.row-added td{{background:rgba(26,143,71,0.04);}}
tr.row-removed td{{background:rgba(179,59,59,0.06);}}
tr.row-modified td{{background:rgba(146,96,0,0.04);}}
tr.row-unchanged td{{color:var(--muted);}}
tr.row-unchanged .status-badge{{opacity:.65;}}
.file-path{{font-family:ui-monospace,monospace;font-size:11px;max-width:340px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:inline-block;vertical-align:middle;}}
.absent{{color:var(--muted);font-style:italic;}}
.pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
.pagination-info{{font-size:12px;color:var(--muted);}}
.pagination-btns{{display:flex;gap:5px;}}
.pg-btn{{min-width:32px;min-height:32px;display:inline-flex;align-items:center;justify-content:center;border-radius:7px;border:1px solid var(--line);background:var(--surface-2);color:var(--text);font-size:12px;font-weight:700;cursor:pointer;transition:background .12s;}}
.pg-btn:hover:not(:disabled){{background:var(--line);}}
.pg-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
.pg-btn:disabled{{opacity:.35;cursor:default;}}
select.per-page{{border:1px solid var(--line-strong);border-radius:7px;background:var(--surface-2);color:var(--text);padding:4px 9px;font-size:12px;cursor:pointer;}}
.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;}}
.export-btn:hover{{background:var(--line);}}
.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{{display:block;}}.status-dot{{display:inline-block;width:8px;height:8px;border-radius:50%;background:#26d768;box-shadow:0 0 0 3px rgba(38,215,104,0.18);flex-shrink:0;}}
.site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
.site-footer a{{color:var(--muted);}}
body.pdf-mode .top-nav,body.pdf-mode .background-watermarks,body.pdf-mode #code-particles,body.pdf-mode .export-group,body.pdf-mode .btn-back,body.pdf-mode .chart-toolbar,body.pdf-mode .filter-tabs-row,body.pdf-mode .filter-tabs,body.pdf-mode .pagination,body.pdf-mode select.per-page,body.pdf-mode .submod-scope-bar,body.pdf-mode .settings-modal,body.pdf-mode .site-footer{{display:none!important;}}
body.pdf-mode{{background:#fff!important;}}
.mc-modal-overlay{{position:fixed;inset:0;z-index:8000;background:rgba(0,0,0,0.52);display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;transition:opacity .18s ease;}}
.mc-modal-overlay.open{{opacity:1;pointer-events:auto;}}
.mc-modal{{background:var(--surface);border:1px solid var(--line-strong);border-radius:16px;box-shadow:0 24px 64px rgba(0,0,0,0.28);max-width:1000px;width:94%;max-height:86vh;overflow-y:auto;position:relative;}}
.mc-modal-head{{background:var(--nav);color:#fff;padding:16px 20px;border-radius:14px 14px 0 0;display:flex;justify-content:space-between;align-items:flex-start;gap:12px;}}
.mc-modal-title{{font-size:18px;font-weight:800;}}
.mc-modal-sub{{font-size:12px;opacity:.72;margin-top:3px;word-break:break-all;}}
.mc-modal-close{{background:rgba(255,255,255,0.18);border:none;color:#fff;width:28px;height:28px;border-radius:50%;cursor:pointer;font-size:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}}
.mc-modal-close:hover{{background:rgba(255,255,255,0.32);}}
.mc-modal-body{{padding:18px 22px;}}
.mc-modal-sec{{margin-bottom:20px;}}
.mc-modal-sec-title{{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:10px;}}
.mc-modal-stats{{display:flex;flex-wrap:nowrap;gap:8px;margin-bottom:8px;}}
.mc-modal-stat{{flex:1 1 0;min-width:0;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:10px 12px;cursor:default;transition:transform .15s ease,box-shadow .15s ease,border-color .15s ease;}}
.mc-modal-stat:hover{{transform:translateY(-3px);box-shadow:0 8px 22px rgba(196,92,16,0.20);border-color:var(--oxide);}}
.mc-modal-stat-val{{font-size:17px;font-weight:900;color:var(--oxide);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}
.mc-modal-stat-lbl{{font-size:10px;font-weight:700;text-transform:uppercase;color:var(--muted);letter-spacing:.05em;margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}
.mc-modal-row{{display:flex;gap:14px;font-size:14px;padding:9px 0;border-bottom:1px solid var(--line);align-items:baseline;}}
.mc-modal-row:last-child{{border-bottom:none;}}
.mc-modal-key{{color:var(--muted);font-weight:700;font-size:12px;text-transform:uppercase;letter-spacing:.04em;flex-shrink:0;min-width:160px;}}
.mc-modal-val{{color:var(--text);font-size:14.5px;font-weight:600;word-break:break-all;}}
.mc-modal-val a{{color:var(--oxide);text-decoration:none;font-weight:700;}}
.mc-modal-val a:hover{{text-decoration:underline;}}
body.dark-theme .mc-modal-stat{{background:rgba(255,255,255,0.07);}}
body.dark-theme .mc-modal-stat:hover{{box-shadow:0 8px 22px rgba(0,0,0,0.40);}}
.mc-modal-stat[data-tip]{{cursor:help;}}
#mc-stat-tt{{display:none;position:fixed;background:rgba(15,10,6,0.96);color:rgba(255,255,255,0.94);border-radius:8px;padding:9px 13px;font-size:12.5px;font-weight:500;line-height:1.5;pointer-events:none;z-index:9001;box-shadow:0 6px 22px rgba(0,0,0,0.34);max-width:300px;border:1px solid rgba(255,255,255,0.12);}}
.mc-card{{cursor:pointer;}}
.mc-card:hover{{transform:translateY(-4px);box-shadow:0 10px 28px rgba(196,92,16,0.24);z-index:10;}}
</style>
</head>
<body>
<div class="background-watermarks" aria-hidden="true">
<img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
<img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
<img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
<img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
<img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
<img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
</div>
<div class="code-particles" id="code-particles" aria-hidden="true"></div>
<div class="top-nav">
<div class="top-nav-inner">
<a class="brand" href="/">
<img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
<div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Multi-Scan Timeline</div></div>
</a>
<div class="nav-right">
<a class="nav-pill" href="/">Home</a>
<div class="nav-dropdown">
<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>
<div class="nav-dropdown-menu">
<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>
</div>
</div>
<a class="nav-pill" href="/compare-scans" {nav_compare_active}>Compare Scans</a>
<a class="nav-pill" href="/test-metrics">Test Metrics</a>
<div class="nav-dropdown">
<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>
<div class="nav-dropdown-menu">
<a href="/integrations"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
</div>
</div>
<div class="server-status-wrap" id="server-status-wrap">
<div class="nav-pill server-online-pill" id="server-status-pill">
<span class="status-dot" id="status-dot"></span>
<span id="server-status-label">Server</span>
<span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
</div>
<div class="server-status-tip">
OxideSLOC is running — accessible on your network.
<span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
</div>
</div>
<button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
<svg viewBox="0 0 24 24" 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>
</button>
<button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
<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>
<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>
</button>
</div>
</div>
</div>
<div class="page">
<!-- Hero header -->
<div class="mc-hero">
<div class="mc-hero-header">
<div>
<div class="mc-title">Multi-Scan Timeline</div>
<p class="mc-desc">Side-by-side metric comparison across multiple scans — code line progression, file changes, and language breakdown.</p>
<div class="mc-subtitle">{scope_label}{n} scans · project: <strong>{project_label}</strong></div>
</div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;flex-shrink:0;">
<a class="btn-back" href="/compare-scans"><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> Compare Scans</a>
<div class="export-group" id="mc-top-export-group">
<button type="button" class="export-btn" id="mc-top-export-html-btn" title="Export this page as a standalone HTML report"><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> Export HTML</button>
<button type="button" class="export-btn" id="mc-top-export-pdf-btn" title="Export this page as a PDF report"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg> Export PDF</button>
</div>
</div>
</div>
{scope_bar_html}
<!-- Scan strip -->
<div class="{mc_strip_class}">{scan_strip}</div>
</div>
<!-- Summary metrics table -->
<div class="panel">
<div class="panel-title">Metric Progression</div>
<div class="table-wrap">
<table class="metrics-table">
<thead>{metrics_thead}</thead>
<tbody>{metrics_tbody}</tbody>
</table>
</div>
</div>
<!-- Scan Charts -->
<div class="panel" id="mc-charts-panel">
<div class="panel-title" style="margin-bottom:14px;">Scan Delta Charts</div>
<div class="ic-grid">
<!-- Timeline line chart — spans full width -->
<div class="ic-card" style="grid-column:span 2">
<div class="ic-card-h2-row">
<span class="ic-card-h2">Timeline</span>
<div class="chart-toolbar" style="margin:0">
<button class="chart-metric-btn active" data-metric="code">Code Lines</button>
<button class="chart-metric-btn" data-metric="files">Files</button>
<button class="chart-metric-btn" data-metric="comments">Comments</button>
<button class="chart-metric-btn" data-metric="tests">Tests</button>
<button class="chart-metric-btn" data-metric="cov">Coverage</button>
</div>
</div>
<div class="chart-wrap"><svg id="mc-chart" height="280"></svg></div>
</div>
<!-- Code Metrics: Scan 1 vs Latest -->
<div class="ic-card">
<div class="ic-card-h2">Code Metrics — Scan 1 vs Latest</div>
<div class="ic-leg"><span class="ic-leg-item" data-highlight="Code Lines"><span class="ic-dot" style="background:#93C5FD"></span><span style="color:#2563EB;font-weight:600">Code Lines</span></span><span class="ic-leg-item" data-highlight="Files"><span class="ic-dot" style="background:#C4B5FD"></span><span style="color:#7C3AED;font-weight:600">Files</span></span><span class="ic-leg-item" data-highlight="Comments"><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 = scan 1)</span></div>
<div id="mc-ic-c1"></div>
</div>
<!-- Language Code Delta -->
<div class="ic-card" id="mc-ic-lang-card">
<div class="ic-card-h2">Language Code Delta</div>
<div id="mc-ic-c3"></div>
</div>
<!-- Delta by Metric -->
<div class="ic-card">
<div class="ic-card-h2">Delta by Metric</div>
<div id="mc-ic-c2"></div>
</div>
<!-- File Change Distribution -->
<div class="ic-card">
<div class="ic-card-h2">File Change Distribution</div>
<div id="mc-ic-c4"></div>
</div>
</div>
</div>
<!-- File matrix table -->
<div class="panel">
<div class="panel-title">File Matrix <span style="font-size:11px;font-weight:400;color:var(--muted);margin-left:8px;text-transform:none;letter-spacing:0;">{total_files} files</span></div>
<div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:14px;">
<div class="filter-tabs-row" style="margin-bottom:0;gap:6px;">
<button class="tab-btn tab-all active" data-status="">All ({total_files})</button>
<button class="tab-btn tab-modified" data-status="modified">Modified ({files_modified})</button>
<button class="tab-btn tab-added" data-status="added">Added ({files_added})</button>
<button class="tab-btn tab-removed" data-status="removed">Removed ({files_removed})</button>
<button class="tab-btn tab-unchanged" data-status="unchanged">Unchanged ({files_unchanged})</button>
</div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;flex-shrink:0;">
<span class="delta-note">* Δ = delta (change from scan 1 → latest)</span>
<div class="export-group">
<button type="button" class="export-btn" id="mc-file-reset-btn">↻ Reset</button>
<button type="button" class="export-btn" id="export-csv-btn">
<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>
CSV
</button>
<button type="button" class="export-btn" id="mc-file-xls-btn">
<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>
Excel
</button>
</div>
</div>
</div>
<div class="table-wrap">
<table id="file-table">
<thead>
<tr>
<th class="left sortable" data-sort-col="p" data-sort-type="str">File <span class="sort-icon">↕</span></th>
<th class="left sortable" data-sort-col="l" data-sort-type="str">Language <span class="sort-icon">↕</span></th>
<th class="left sortable" data-sort-col="s" data-sort-type="str">Status <span class="sort-icon">↕</span></th>
{file_col_headers}
<th class="file-net-col sortable" data-sort-col="t" data-sort-type="num">Net Δ <span class="sort-icon">↕</span></th>
</tr>
</thead>
<tbody id="file-tbody"></tbody>
</table>
</div>
<div class="pagination">
<span class="pagination-info" id="pg-info"></span>
<div class="pagination-btns" id="pg-btns"></div>
<div style="display:flex;align-items:center;gap:6px;">
<span style="font-size:12px;color:var(--muted)">Show</span>
<select class="per-page" id="per-page-sel">
<option value="25" selected>25 per page</option>
<option value="50">50 per page</option>
<option value="100">100 per page</option>
</select>
</div>
</div>
</div>
</div>
<div id="mc-ic-tt"></div>
<footer class="site-footer">
oxide-sloc v{version} — local code metrics workbench ·
Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
· <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
· <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
· <a href="/api-docs" rel="noopener">REST API</a>
</footer>
<script nonce="{csp_nonce}">
(function(){{
// ── Dark theme ───────────────────────────────────────────────────────────
try{{if(localStorage.getItem('sloc-dark')==='1')document.body.classList.add('dark-theme');}}catch(e){{}}
var tt=document.getElementById('theme-toggle');
if(tt)tt.addEventListener('click',function(){{
var on=document.body.classList.toggle('dark-theme');
try{{localStorage.setItem('sloc-dark',on?'1':'0');}}catch(e){{}}
renderChart(activeMetric);
}});
// ── Code particles ───────────────────────────────────────────────────────
var container=document.getElementById('code-particles');
if(container){{
var snips=['multi-scan','timeline','code_lines','fn delta()','+230 loc','-15 files','v1.0','git main','scan 3','commits','trend','coverage','tests: 145','sloc_core','analyze()'];
for(var i=0;i<28;i++){{
(function(idx){{
var el=document.createElement('span');el.className='code-particle';
el.textContent=snips[idx%snips.length];
el.style.left=(Math.random()*94+2).toFixed(1)+'%';
el.style.top=(Math.random()*88+6).toFixed(1)+'%';
el.style.setProperty('--rot',(Math.random()*26-13).toFixed(1)+'deg');
el.style.setProperty('--op',(Math.random()*0.08+0.05).toFixed(3));
el.style.animationDuration=(Math.random()*10+9).toFixed(1)+'s';
el.style.animationDelay='-'+(Math.random()*18).toFixed(1)+'s';
container.appendChild(el);
}})(i);
}}
}}
// ── Watermarks ───────────────────────────────────────────────────────────
var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
if(wms.length){{
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),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;}});
}}
// ── Settings / colour scheme modal ───────────────────────────────────────
(function(){{
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'}}];
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);}});}}
try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a)ap(sv);else ap(S[0]);}}catch(e){{ap(S[0]);}}
function init(){{
var btn=document.getElementById('settings-btn');if(!btn)return;
var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close-btn" 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>';
document.body.appendChild(m);
var g=document.getElementById('scheme-grid');
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);}});
var cl=document.getElementById('settings-close-btn');
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');}});
if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
}}
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
}})();
// ── Timezone support for scan timestamps ─────────────────────────────────
(function(){{
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('.mc-ts-local[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 storedTz;try{{storedTz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{storedTz='America/Los_Angeles';}}
window.applyTz(storedTz);
function wireTzSelect(){{var tzSel=document.getElementById('tz-select');if(!tzSel)return;tzSel.value=storedTz;tzSel.addEventListener('change',function(){{window.applyTz(this.value);}});}}
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',wireTzSelect);else setTimeout(wireTzSelect,50);
}})();
// ── Data ────────────────────────────────────────────────────────────────
var POINTS={points_json};
var FILES={file_matrix_json};
var N={n};
// ── fmt helper ───────────────────────────────────────────────────────────
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(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}}
function fmtFull(n){{return Number(n).toLocaleString();}}
function fmtDelta(n){{return n>0?'+'+fmt(n):fmt(n);}}
// ── Export filename: <project>_<n_scans>_<first_scan_short_commit> ──
function mcExportProj(){{return ('{project_label}'.replace(/[^A-Za-z0-9._-]+/g,'-').replace(/^-+|-+$/g,''))||'project';}}
function mcShortRef(p,i){{var c=(p&&p.commit?String(p.commit):'').replace(/[^A-Za-z0-9]/g,'').slice(0,7);if(c)return c;var r=(p&&p.run_id?String(p.run_id):'').replace(/[^A-Za-z0-9]/g,'').slice(0,7);return r||('scan'+(i+1));}}
function mcExportBase(){{var first=POINTS.length?mcShortRef(POINTS[0],0):'scan1';return mcExportProj()+'_'+POINTS.length+'_'+first;}}
function mcExportName(ext){{return mcExportBase()+'.'+ext;}}
// ── Timeline chart ───────────────────────────────────────────────────────
var activeMetric='code';
var metricKey={{code:'code',files:'files',comments:'comments',tests:'tests',cov:'cov'}};
var metricLabel={{code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'}};
function renderChart(metric){{
var svg=document.getElementById('mc-chart');if(!svg)return;
var W=svg.getBoundingClientRect().width||800,H=280;
svg.setAttribute('height',H);
var pad={{l:62,r:20,t:32,b:72}};
var dark=document.body.classList.contains('dark-theme');
var pts=POINTS.map(function(p){{return p[metric]!=null?Number(p[metric]):null;}});
var valid=pts.filter(function(v){{return v!=null;}});
if(!valid.length){{var _nd_dark=document.body.classList.contains('dark-theme');var _nd_bg=_nd_dark?'#241a12':'#fbf7f2';var _nd_tc=_nd_dark?'rgba(255,255,255,0.30)':'rgba(67,52,45,0.32)';var _nd_ts=_nd_dark?'rgba(255,255,255,0.55)':'rgba(67,52,45,0.60)';var _nd_lbl=(metricLabel[metric]||metric);var _nd_cov=metric==='cov';var _nd_msg=_nd_cov?'No coverage data for these scans':'No '+_nd_lbl.toLowerCase()+' recorded';var _nd_sub=_nd_cov?'Coverage appears once test results are captured during a scan.':'None of the selected scans reported a value for this metric.';var _cx=W/2,_cy=H/2;svg.setAttribute('viewBox','0 0 '+W+' '+H);svg.innerHTML='<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+_nd_bg+'" rx="8"/>'+'<g opacity="0.55"><rect x="'+(_cx-28).toFixed(1)+'" y="'+(_cy-50).toFixed(1)+'" width="56" height="34" rx="5" fill="none" stroke="'+_nd_tc+'" stroke-width="1.6"/><polyline points="'+(_cx-20).toFixed(1)+','+(_cy-24).toFixed(1)+' '+(_cx-7).toFixed(1)+','+(_cy-30).toFixed(1)+' '+(_cx+6).toFixed(1)+','+(_cy-26).toFixed(1)+' '+(_cx+20).toFixed(1)+','+(_cy-34).toFixed(1)+'" fill="none" stroke="'+_nd_tc+'" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></g>'+'<text x="'+_cx.toFixed(1)+'" y="'+(_cy+4).toFixed(1)+'" text-anchor="middle" font-size="14" font-weight="700" fill="'+_nd_ts+'">'+escHtml(_nd_msg)+'</text>'+'<text x="'+_cx.toFixed(1)+'" y="'+(_cy+24).toFixed(1)+'" text-anchor="middle" font-size="11.5" fill="'+_nd_tc+'">'+escHtml(_nd_sub)+'</text>';return;}}
var minV=Math.min.apply(null,valid),maxV=Math.max.apply(null,valid);
if(minV===maxV){{minV=Math.max(0,minV-1);maxV=maxV+1;}}
var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
function xOf(i){{return pad.l+(N===1?plotW/2:i/(N-1)*plotW);}}
function yOf(v){{return pad.t+plotH-(v-minV)/(maxV-minV)*plotH;}}
var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
var lineColor='#d37a4c';var dotColor='#d37a4c';var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
var parts=[];
parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
for(var gi=0;gi<5;gi++){{var gy=pad.t+plotH/4*gi;parts.push('<line x1="'+pad.l+'" y1="'+gy.toFixed(1)+'" x2="'+(W-pad.r)+'" y2="'+gy.toFixed(1)+'" stroke="'+gridColor+'" stroke-width="1"/>');var gv=maxV-(maxV-minV)/4*gi;parts.push('<text x="'+(pad.l-6)+'" y="'+(gy+4).toFixed(1)+'" text-anchor="end" font-size="10" fill="'+textColor+'">'+fmt(gv)+'</text>');}}
var areaD='M '+xOf(0)+' '+(pad.t+plotH);
var lineD='';var firstPt=true;
for(var i=0;i<N;i++){{if(pts[i]==null)continue;var cx=xOf(i),cy=yOf(pts[i]);areaD+=' L '+cx.toFixed(1)+' '+cy.toFixed(1);if(firstPt){{lineD='M '+cx.toFixed(1)+' '+cy.toFixed(1);firstPt=false;}}else{{lineD+=' L '+cx.toFixed(1)+' '+cy.toFixed(1);}}}}
areaD+=' L '+xOf(N-1)+' '+(pad.t+plotH)+' Z';
parts.push('<path d="'+areaD+'" fill="'+areaColor+'"/>');
parts.push('<path d="'+lineD+'" fill="none" stroke="'+lineColor+'" stroke-width="2.2" stroke-linejoin="round"/>');
for(var i=0;i<N;i++){{
if(pts[i]==null)continue;
var cx=xOf(i),cy=yOf(pts[i]);
var p=POINTS[i];var lbl=(p.commit||'').substring(0,7)||(i+1)+'';
var hasTag=p.tags&&p.tags.length>0;
// Permanent Y-value label above the dot
parts.push('<text x="'+cx.toFixed(1)+'" y="'+(cy-11).toFixed(1)+'" text-anchor="middle" font-size="11" font-weight="600" fill="'+textColor+'">'+fmt(pts[i])+'</text>');
parts.push('<circle cx="'+cx.toFixed(1)+'" cy="'+cy.toFixed(1)+'" r="'+(hasTag?5.5:4)+'" fill="'+(hasTag?'#6f9bff':dotColor)+'" stroke="'+(dark?'#241a12':'#fbf7f2')+'" stroke-width="1.5" style="cursor:pointer" onclick="window.location=\'/runs/report.html/'+p.run_id+'\'"/>');
var xanchor=i===0?'start':i===N-1?'end':'middle';
// X-axis label at 2× the original size (18 px)
parts.push('<text x="'+cx.toFixed(1)+'" y="'+(H-pad.b+22)+'" text-anchor="'+xanchor+'" font-size="18" fill="'+textColor+'" font-family="ui-monospace,monospace">'+escHtml(lbl)+'</text>');
}}
parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escHtml(metricLabel[metric]||metric)+'</text>');
svg.setAttribute('viewBox','0 0 '+W+' '+H);
svg.innerHTML=parts.join('');
// ── Interactive hover: vertical crosshair + tooltip ───────────────────
svg.onmousemove=function(e){{
var rect=svg.getBoundingClientRect();
var scaleX=W/rect.width;
var mouseX=(e.clientX-rect.left)*scaleX;
var nearest=-1,minDist=Infinity;
for(var k=0;k<N;k++){{if(pts[k]==null)continue;var dx=Math.abs(xOf(k)-mouseX);if(dx<minDist){{minDist=dx;nearest=k;}}}}
if(nearest<0)return;
var nc=xOf(nearest),ny=yOf(pts[nearest]);
var xhair=svg.querySelector('.mc-xhair');
if(!xhair){{xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','mc-xhair');svg.appendChild(xhair);}}
xhair.innerHTML='<line x1="'+nc.toFixed(1)+'" y1="'+pad.t+'" x2="'+nc.toFixed(1)+'" y2="'+(pad.t+plotH)+'" stroke="rgba(211,122,76,0.55)" stroke-width="1.5" stroke-dasharray="4,3" pointer-events="none"/>';
var tt=document.getElementById('mc-ic-tt');if(!tt)return;
var pp=POINTS[nearest];var clbl=(pp.commit||'').substring(0,7)||(nearest+1)+'';
tt.innerHTML='<strong>Scan '+(nearest+1)+'</strong> <span style="font-family:monospace;font-size:11px;opacity:.75">'+escHtml(clbl)+'</span><br>'+escHtml(metricLabel[metric]||metric)+': <strong>'+fmtFull(pts[nearest])+'</strong>';
var bx=rect.left+(nc/W*rect.width)+18;
if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
tt.style.left=bx+'px';tt.style.top=(e.clientY-38)+'px';tt.style.display='block';
}};
svg.onmouseleave=function(){{
var xhair=svg.querySelector('.mc-xhair');if(xhair)xhair.innerHTML='';
var tt=document.getElementById('mc-ic-tt');if(tt)tt.style.display='none';
}};
}}
function escHtml(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
document.querySelectorAll('.chart-metric-btn').forEach(function(btn){{
btn.addEventListener('click',function(){{
activeMetric=this.dataset.metric;
document.querySelectorAll('.chart-metric-btn').forEach(function(b){{b.classList.remove('active');}});
this.classList.add('active');
renderChart(activeMetric);
}});
}});
if(typeof ResizeObserver!=='undefined'){{
new ResizeObserver(function(){{renderChart(activeMetric);}}).observe(document.getElementById('mc-chart'));
}}
renderChart(activeMetric);
// ── File matrix table ────────────────────────────────────────────────────
var activeStatus='';
var currentPage=1;
var perPage=25;
var mcSortCol=null,mcSortAsc=true;
function getFiltered(){{
var data=!activeStatus?FILES:FILES.filter(function(f){{return f.s===activeStatus;}});
if(!mcSortCol)return data;
var asc=mcSortAsc;
return data.slice().sort(function(a,b){{
var va,vb;
if(mcSortCol==='p'){{va=a.p||'';vb=b.p||'';}}
else if(mcSortCol==='l'){{va=a.l||'';vb=b.l||'';}}
else if(mcSortCol==='s'){{va=a.s||'';vb=b.s||'';}}
else if(mcSortCol==='t'){{va=a.t||0;vb=b.t||0;return asc?va-vb:vb-va;}}
else{{return 0;}}
if(asc)return va<vb?-1:va>vb?1:0;
return va<vb?1:va>vb?-1:0;
}});
}}
function renderFilePage(){{
var filtered=getFiltered();
var total=filtered.length;
var totalPages=Math.max(1,Math.ceil(total/perPage));
if(currentPage>totalPages)currentPage=totalPages;
var start=(currentPage-1)*perPage,end=Math.min(start+perPage,total);
var tbody=document.getElementById('file-tbody');if(!tbody)return;
var rows=[];
for(var i=start;i<end;i++){{
var f=filtered[i];
var cells='<td class="left"><span class="file-path" title="'+escHtml(f.p)+'">'+escHtml(f.p)+'</span></td>';
cells+='<td class="left">'+(f.l?escHtml(f.l):'<span class="absent">\u2014</span>')+'</td>';
cells+='<td class="left"><span class="status-badge '+f.s+'">'+f.s+'</span></td>';
for(var j=0;j<N;j++){{
var cv=f.c[j];
cells+='<td class="file-scan-col">'+(cv!=null?fmt(cv):'<span class="absent">\u2014</span>')+'</td>';
if(j<N-1){{
var dv=f.d[j+1];
cells+='<td class="file-delta-col '+(dv!=null?dv>0?'pos':dv<0?'neg':'zero':'absent-delta')+'">'+
(dv!=null?fmtDelta(dv):'<span class="absent">\u2014</span>')+'</td>';
}}
}}
var tc=f.t;
cells+='<td class="file-net-col '+(tc>0?'pos':tc<0?'neg':'zero')+'">'+fmtDelta(tc)+'</td>';
rows.push('<tr class="row-'+f.s+'">'+cells+'</tr>');
}}
tbody.innerHTML=rows.join('');
var info=document.getElementById('pg-info');
if(info)info.textContent='Showing '+(total?start+1:0)+'–'+end+' of '+total+' files';
renderPgBtns(totalPages);
}}
function renderPgBtns(totalPages){{
var wrap=document.getElementById('pg-btns');if(!wrap)return;
var btns=[];
function mkBtn(label,page,active,disabled){{
var cls='pg-btn'+(active?' active':'')+(disabled?' disabled':'');
return '<button class="'+cls+'" data-pg="'+page+'" '+(disabled?'disabled':'')+'>'+label+'</button>';
}}
btns.push(mkBtn('‹',currentPage-1,false,currentPage<=1));
var s=Math.max(1,currentPage-2),e=Math.min(totalPages,currentPage+2);
if(s>1)btns.push(mkBtn('1',1,false,false));
if(s>2)btns.push('<span class="pg-btn" style="pointer-events:none">…</span>');
for(var p=s;p<=e;p++)btns.push(mkBtn(p,p,p===currentPage,false));
if(e<totalPages-1)btns.push('<span class="pg-btn" style="pointer-events:none">…</span>');
if(e<totalPages)btns.push(mkBtn(totalPages,totalPages,false,false));
btns.push(mkBtn('›',currentPage+1,false,currentPage>=totalPages));
wrap.innerHTML=btns.join('');
wrap.querySelectorAll('.pg-btn[data-pg]').forEach(function(b){{
b.addEventListener('click',function(){{
var pg=parseInt(this.dataset.pg,10);
if(pg>=1&&pg<=totalPages){{currentPage=pg;renderFilePage();}}
}});
}});
}}
// Tab filter
document.querySelectorAll('.tab-btn').forEach(function(btn){{
btn.addEventListener('click',function(){{
activeStatus=this.dataset.status||'';
currentPage=1;
document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
this.classList.add('active');
renderFilePage();
}});
}});
// Per-page selector
var ppSel=document.getElementById('per-page-sel');
if(ppSel)ppSel.addEventListener('change',function(){{perPage=parseInt(this.value,10)||25;currentPage=1;renderFilePage();}});
// ── Column header sort ───────────────────────────────────────────────────
Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(th){{
th.addEventListener('click',function(){{
var col=th.dataset.sortCol;
if(mcSortCol===col){{mcSortAsc=!mcSortAsc;}}else{{mcSortCol=col;mcSortAsc=true;}}
Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
var si=t.querySelector('.sort-icon');if(si)si.innerHTML='↕';t.classList.remove('sort-asc','sort-desc');
}});
th.classList.add(mcSortAsc?'sort-asc':'sort-desc');
var si=th.querySelector('.sort-icon');if(si)si.innerHTML=mcSortAsc?'↑':'↓';
currentPage=1;renderFilePage();
}});
}});
// Reset button also clears sort
var mcResetBtn=document.getElementById('mc-file-reset-btn');
if(mcResetBtn)mcResetBtn.addEventListener('click',function(){{
mcSortCol=null;mcSortAsc=true;
Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
var si=t.querySelector('.sort-icon');if(si)si.innerHTML='↕';t.classList.remove('sort-asc','sort-desc');
}});
activeStatus='';currentPage=1;
document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
var allBtn=document.querySelector('.tab-btn');if(allBtn)allBtn.classList.add('active');
renderFilePage();
}});
renderFilePage();
// ── CSV export ───────────────────────────────────────────────────────────
var exportBtn=document.getElementById('export-csv-btn');
if(exportBtn)exportBtn.addEventListener('click',function(){{
var header=['File','Language','Status'];
for(var i=0;i<N;i++){{header.push('Scan '+(i+1)+' Code');if(i<N-1)header.push('Delta->'+(i+2));}}
header.push('Net Delta');
var rows=[header.map(function(h){{return '"'+h.replace(/"/g,'""')+'"';}}).join(',')];
var filtered=getFiltered();
filtered.forEach(function(f){{
var cols=['"'+f.p.replace(/"/g,'""')+'"','"'+(f.l||'')+'"','"'+f.s+'"'];
for(var j=0;j<N;j++){{
cols.push(f.c[j]!=null?f.c[j]:'');
if(j<N-1)cols.push(f.d[j+1]!=null?f.d[j+1]:'');
}}
cols.push(f.t);
rows.push(cols.join(','));
}});
var blob=new Blob([rows.join('\r\n')],{{type:'text/csv'}});
var a=document.createElement('a');a.href=URL.createObjectURL(blob);
a.download=mcExportName('csv');a.click();
}});
// ── File matrix extra export buttons ─────────────────────────────────────
(function(){{
var resetBtn=document.getElementById('mc-file-reset-btn');
if(resetBtn)resetBtn.addEventListener('click',function(){{
activeStatus='';currentPage=1;
document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
var allBtn=document.querySelector('.tab-btn.tab-all');if(allBtn)allBtn.classList.add('active');
renderFilePage();
}});
// \u2500\u2500 File Matrix Excel export \u2014 Summary + File Delta tabs (matches Scan Delta) \u2500\u2500
function mcSignDelta(v){{if(v==null||v==='')return'';var n=+v;return n>0?'+'+n:String(n);}}
function mcMakeXlsx(fname){{
var filtered=getFiltered();
var enc=new TextEncoder();
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;}}
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;}}
function u2(n){{return[n&0xFF,(n>>8)&0xFF];}}
function u4(n){{return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}}
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];}}
function xe(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
function WS(){{
var R=0,buf=[];
function cl(c){{return String.fromCharCode(65+c);}}
function sc(c,v,st){{return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'><v>'+S(v)+'</v></c>';}}
function nc(c,v,st){{return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+(st?' s="'+st+'"':'')+'><v>'+(+v)+'</v></c>';}}
function row(cells){{if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}}
function xml(cw){{return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetViews><sheetView workbookViewId="0"/></sheetViews><sheetFormatPr defaultRowHeight="15"/>'+(cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}}
return{{sc:sc,nc:nc,row:row,xml:xml}};
}}
function dstyle(v){{var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}}
var proj=mcExportProj();
// \u2500\u2500 Summary sheet \u2500\u2500
var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
r1(s1(0,'OxideSLOC \u2014 Multi-Scan Timeline Report',1));
r1(s1(0,proj,2));
var firstTs=POINTS.length?(POINTS[0].scanned||''):'',lastTs=POINTS.length?(POINTS[POINTS.length-1].scanned||''):'';
r1(s1(0,firstTs+' \u2192 '+lastTs+' ('+N+' scans)',2));
r1('');
r1(s1(0,'SCAN SUMMARY',8));
r1(s1(0,'Scan',3)+s1(1,'Commit',3)+s1(2,'Branch',3)+s1(3,'Timestamp',3)+s1(4,'Code Lines',3)+s1(5,'Comment Lines',3)+s1(6,'Files',3)+s1(7,'Tests',3));
POINTS.forEach(function(p,i){{
var sha=(p.commit||'').replace(/[^A-Za-z0-9]/g,'').slice(0,7);
r1(s1(0,'Scan '+(i+1))+s1(1,sha||'\u2014')+s1(2,p.branch||'\u2014')+s1(3,p.scanned||'')+n1(4,p.code,4)+n1(5,p.comments,4)+n1(6,p.files,4)+n1(7,p.tests,4));
}});
r1('');
if(POINTS.length>1){{
var pf=POINTS[0],pl=POINTS[POINTS.length-1];
r1(s1(0,'NET CHANGE (Scan 1 \u2192 Scan '+N+')',8));
r1(s1(0,'Metric',3)+s1(1,'Scan 1',3)+s1(2,'Scan '+N,3)+s1(3,'Delta',3));
var nr=function(lbl,a,b){{var d=(+b)-(+a),ds=d>0?'+'+d:String(d);r1(s1(0,lbl)+n1(1,a,4)+n1(2,b,4)+s1(3,ds,dstyle(ds)));}};
nr('Code Lines',pf.code,pl.code);
nr('Comment Lines',pf.comments,pl.comments);
nr('Files Analyzed',pf.files,pl.files);
nr('Tests',pf.tests,pl.tests);
r1('');
}}
var cMod=0,cAdd=0,cRem=0,cUnch=0;
FILES.forEach(function(f){{var s=f.s;if(s==='modified')cMod++;else if(s==='added')cAdd++;else if(s==='removed')cRem++;else cUnch++;}});
var totF=FILES.length||1;
function pct(n){{return(n/totF*100).toFixed(1)+'%';}}
r1(s1(0,'FILE CHANGES',8));
r1(s1(0,'Category',3)+s1(1,'Count',3)+s1(2,'% of Total',3));
r1(s1(0,'Modified')+n1(1,cMod,4)+s1(2,pct(cMod)));
r1(s1(0,'Added')+n1(1,cAdd,4)+s1(2,pct(cAdd)));
r1(s1(0,'Removed')+n1(1,cRem,4)+s1(2,pct(cRem)));
r1(s1(0,'Unchanged')+n1(1,cUnch,4)+s1(2,pct(cUnch)));
var lm={{}};
FILES.forEach(function(f){{var l=f.l||'Unknown',d=+f.t||0;if(!lm[l])lm[l]={{f:0,d:0}};lm[l].f++;lm[l].d+=d;}});
var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}});
if(langs.length){{
r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
r1(s1(0,'Language',3)+s1(1,'Files',3)+s1(2,'Net Code Delta',3));
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)));}});
}}
var sh1=W1.xml('<col min="1" max="1" width="22" customWidth="1"/><col min="2" max="8" width="15" customWidth="1"/>');
// \u2500\u2500 File Delta sheet \u2500\u2500
var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
var hcells=s2(0,'File',3)+s2(1,'Language',3)+s2(2,'Status',3),hc=3;
for(var hi=0;hi<N;hi++){{hcells+=s2(hc++,'Scan '+(hi+1)+' Code',3);if(hi<N-1)hcells+=s2(hc++,'Delta \u2192 '+(hi+2),3);}}
hcells+=s2(hc,'Net Delta',3);
r2(hcells);
filtered.forEach(function(f){{
var cells=s2(0,f.p)+s2(1,f.l||'')+s2(2,f.s||''),c=3;
for(var j=0;j<N;j++){{cells+=n2(c++,f.c[j]!=null?f.c[j]:'',4);if(j<N-1){{var dv=mcSignDelta(f.d[j+1]);cells+=s2(c++,dv,dstyle(dv));}}}}
var tv=mcSignDelta(f.t);cells+=s2(c,tv,dstyle(tv));
r2(cells);
}});
var ncols=3+N+(N-1)+1;
var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="'+ncols+'" width="13" customWidth="1"/>');
var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+ss.map(function(v){{return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}}).join('')+'</sst>';
var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
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>',
'_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>',
'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>',
'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>',
'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>',
'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2}};
var zparts=[],zcds=[],zoff=0,znf=0;
['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels','xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'].forEach(function(name){{
var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
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]);
var entry=new Uint8Array(lha.length+nb.length+sz);entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);zparts.push(entry);
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));
var cde=new Uint8Array(cda.length+nb.length);cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);zcds.push(cde);
zoff+=entry.length;znf++;
}});
var cdSz=zcds.reduce(function(s,b){{return s+b.length;}},0);
var eocd=[0x50,0x4B,0x05,0x06,0,0,0,0].concat(u2(znf)).concat(u2(znf)).concat(u4(cdSz)).concat(u4(zoff)).concat([0,0]);
var totalLen=zoff+cdSz+eocd.length,out=new Uint8Array(totalLen),pos=0;
zparts.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
zcds.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
out.set(new Uint8Array(eocd),pos);
var blob=new Blob([out],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}});
var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
}}
var xlsBtn=document.getElementById('mc-file-xls-btn');
if(xlsBtn)xlsBtn.addEventListener('click',function(){{mcMakeXlsx(mcExportName('xlsx'));}});
// File matrix HTML export — interactive: sort by column, filter by status
function mcFileBuildHtml(){{
function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
var hdrs=['File','Language','Status'];
for(var _i=0;_i<N;_i++){{hdrs.push('Scan '+(_i+1)+' Code');if(_i<N-1)hdrs.push('\u0394\u2192'+(_i+2));}}
hdrs.push('Net \u0394');
var SI=2;
var allRows=FILES.map(function(f){{var r=[f.p,f.l||'',f.s||''];for(var _i=0;_i<N;_i++){{r.push(f.c[_i]!=null?f.c[_i]:null);if(_i<N-1)r.push(f.d[_i+1]!=null?f.d[_i+1]:null);}}r.push(f.t);return r;}});
var dJson=JSON.stringify(allRows),hJson=JSON.stringify(hdrs);
var cnt={{all:allRows.length}};
allRows.forEach(function(r){{var s=r[SI];cnt[s]=(cnt[s]||0)+1;}});
var now=new Date().toISOString().replace('T',' ').slice(0,16)+' UTC';
var css='body{{margin:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#f5f2ee;color:#111;}}'+
'.hd{{background:#1a2035;color:#fff;padding:14px 20px;display:flex;justify-content:space-between;align-items:flex-start;}}'+
'.brand{{font-size:13px;font-weight:800;color:#c45c10;letter-spacing:.06em;}}'+
'.ttl{{font-size:18px;font-weight:700;margin:2px 0 3px;}}'+
'.sub{{font-size:12px;color:#99aabb;}}'+
'.pg-meta{{font-size:11px;color:#8899aa;text-align:right;line-height:1.8;}}'+
'.wr{{padding:16px 20px;}}'+
'.fbar{{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;}}'+
'.fb{{padding:4px 12px;border-radius:20px;border:1px solid #ccc;background:#fff;font-size:12px;font-weight:600;cursor:pointer;transition:all .12s;}}'+
'.fb.on{{background:#c45c10;color:#fff;border-color:#c45c10;}}'+
'.ibar{{font-size:12px;color:#888;margin-bottom:8px;}}'+
'.tw{{overflow-x:auto;border-radius:10px;box-shadow:0 2px 10px rgba(0,0,0,.09);}}'+
'table{{width:100%;border-collapse:collapse;background:#fff;font-size:12px;}}'+
'thead tr{{background:#1a2035;}}'+
'th{{padding:6px 10px;color:#fff;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;text-align:left;white-space:nowrap;cursor:pointer;user-select:none;}}'+
'th:hover{{background:#2a3050;}}'+
'th span{{margin-left:4px;opacity:.55;font-size:10px;}}'+
'td{{padding:5px 10px;border-bottom:1px solid #f0ece8;}}'+
'tr:nth-child(even) td{{background:#faf7f4;}}'+
'tr:hover td{{background:#f5f0ea;}}'+
'.ap{{color:#2a6846;font-weight:700;}}.an{{color:#b23030;font-weight:700;}}'+
'.ftr{{background:#1a2035;color:#7a8b9c;font-size:10px;padding:7px 20px;display:flex;justify-content:space-between;margin-top:16px;}}';
var thH=hdrs.map(function(h,i){{return'<th data-ci="'+i+'">'+esc(h)+'<span>\u21c5</span></th>';}}).join('');
var fH='<button class="fb on" data-f="">All ('+allRows.length+')</button>'+
(cnt.modified?'<button class="fb" data-f="modified">Modified ('+cnt.modified+')</button>':'')+
(cnt.added?'<button class="fb" data-f="added">Added ('+cnt.added+')</button>':'')+
(cnt.removed?'<button class="fb" data-f="removed">Removed ('+cnt.removed+')</button>':'')+
(cnt.unchanged?'<button class="fb" data-f="unchanged">Unchanged ('+cnt.unchanged+')</button>':'');
var inlineJs='var ALL='+dJson+',HDRS='+hJson+',SI='+SI+',sc=-1,sd=1,sf="";'+
'function fc(v,ci){{if(v==null)return"—";var s=String(v);'+
'if(ci===SI){{return s==="added"?"<span class=\\"ap\\">added<\\/span>":s==="removed"?"<span class=\\"an\\">removed<\\/span>":s||"—";}}'+
'var n=Number(v);if(ci>SI&&!isNaN(n)&&n!==0){{return n>0?"<span class=\\"ap\\">+"+n.toLocaleString()+"<\\/span>":"<span class=\\"an\\">"+n.toLocaleString()+"<\\/span>";}}'+
'if(ci>=3&&typeof v==="number")return Number(v).toLocaleString();'+
'return s.length>80?"<abbr title=\\""+s.replace(/"/g,""")+"\\" style=\\"cursor:help\\">"+s.slice(0,78)+"\u2026<\\/abbr>":esc(s);}}'+
'function esc(s){{return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");}}'+
'function render(){{var data=sf?ALL.filter(function(r){{return r[SI]===sf;}}):ALL.slice();'+
'if(sc>=0)data.sort(function(a,b){{var av=a[sc],bv=b[sc];var an=Number(av),bn=Number(bv);'+
'return(!isNaN(an)&&!isNaN(bn)?an-bn:String(av||"").localeCompare(String(bv||"")))*sd;}});'+
'document.getElementById("tb").innerHTML=data.map(function(r){{return"<tr>"+HDRS.map(function(h,ci){{return"<td>"+fc(r[ci],ci)+"<\\/td>";}}).join("")+"<\\/tr>";}}).join("")'+
'||"<tr><td colspan=\\""+HDRS.length+"\\" style=\\"text-align:center;color:#aaa;padding:14px\\">No files match.<\\/td><\\/tr>";'+
'document.getElementById("ic").textContent=data.length+" of "+ALL.length+" files";}}'+
'document.querySelectorAll(".fb").forEach(function(b){{b.onclick=function(){{sf=this.dataset.f||"";'+
'document.querySelectorAll(".fb").forEach(function(x){{x.classList.remove("on");}});this.classList.add("on");render();}};}} );'+
'document.querySelectorAll("th[data-ci]").forEach(function(th){{th.onclick=function(){{var ci=+this.dataset.ci;'+
'sd=(sc===ci)?-sd:1;sc=ci;'+
'document.querySelectorAll("th[data-ci]").forEach(function(t){{t.querySelector("span").textContent="\u21c5";}});'+
'this.querySelector("span").textContent=sd>0?"\u25b2":"\u25bc";render();}};}} );'+
'render();';
return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>Multi-Scan File Matrix<\/title><style>'+css+'<\/style><\/head><body>'+
'<div class="hd"><div><div class="brand">oxide-sloc<\/div><div class="ttl">Multi-Scan File Matrix<\/div>'+
'<div class="sub">{project_label} · {n} scans<\/div><\/div>'+
'<div class="pg-meta">'+allRows.length+' files<br>Generated: '+now+'<\/div><\/div>'+
'<div class="wr"><div class="fbar">'+fH+'<\/div><div class="ibar" id="ic"><\/div>'+
'<div class="tw"><table><thead><tr>'+thH+'<\/tr><\/thead><tbody id="tb"><\/tbody><\/table><\/div><\/div>'+
'<div class="ftr"><span>oxide-sloc v{version}<\/span><span>Multi-Scan File Matrix<\/span><span>{project_label}<\/span><\/div>'+
'<script>'+inlineJs+'<\/script><\/body><\/html>';
}}
var htmlBtn=document.getElementById('mc-file-html-btn');
if(htmlBtn)htmlBtn.addEventListener('click',function(){{
var h=mcFileBuildHtml();
var blob=new Blob([h],{{type:'text/html;charset=utf-8;'}});
var a=document.createElement('a');a.href=URL.createObjectURL(blob);
a.download=mcExportName('files.html');a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
}});
var pdfBtn=document.getElementById('mc-file-pdf-btn');
if(pdfBtn)pdfBtn.addEventListener('click',function(){{
var btn=pdfBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Generating PDF\u2026';
var h=mcBuildPdfHtml();
fetch('/export/pdf',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{html:h,filename:mcExportName('files.pdf')}})}})
.then(function(r){{if(!r.ok)throw new Error('PDF failed: '+r.status);return r.blob();}})
.then(function(blob){{var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=mcExportName('files.pdf');a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);}})
.catch(function(e){{alert('PDF export failed: '+e.message);}})
.finally(function(){{btn.disabled=false;btn.innerHTML=orig;}});
}});
}})();
// ── Inline scan charts (matching Scan Delta layout) ──────────────────────
(function(){{
var OX='#C45C10',GN='#2A6846',RD='#B23030',LGY='#DDDDDD';
function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
function fmt2(n){{var v=Number(n),a=Math.abs(v);if(a>=1e6)return(v/1e6).toFixed(1).replace(/\.0$/,'')+'M';if(a>=1e4)return(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}}
function px(n){{return Math.round(n);}}
var _tt=document.getElementById('mc-ic-tt');
function btt(l,v){{return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}}
function addTT(el){{
if(!el)return;
el.addEventListener('mouseover',function(e){{
var t=e.target.closest('[data-ttl]');
if(t&&_tt){{
var ttl=t.getAttribute('data-ttl');
_tt.innerHTML='<strong>'+ttl+'</strong><br>'+t.getAttribute('data-ttv');
_tt.style.display='block';mvTT(e);
el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
el.querySelectorAll('[data-ttl]').forEach(function(x){{if(x.getAttribute('data-ttl')===ttl)x.style.filter='brightness(1.2)';}});
}} else {{
if(_tt)_tt.style.display='none';
el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
}}
}});
el.addEventListener('mouseleave',function(){{
if(_tt)_tt.style.display='none';
el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
}});
el.addEventListener('mousemove',function(e){{mvTT(e);}});
}}
function mvTT(e){{if(!_tt)return;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';}}
if(N<2)return;
var p0=POINTS[0],pLast=POINTS[N-1];
// Chart 1: Code Metrics — Scan 1 vs Latest (grouped bars, same structure as Scan Delta)
var c1mets=[
{{l:'Code Lines',b:Number(p0.code),c:Number(pLast.code),bc:'#93C5FD',cc:'#2563EB'}},
{{l:'Files',b:Number(p0.files),c:Number(pLast.files),bc:'#C4B5FD',cc:'#7C3AED'}},
{{l:'Comments',b:Number(p0.comments),c:Number(pLast.comments),bc:'#6EE7B7',cc:'#0D9488'}}
];
var maxV1=Math.max.apply(null,c1mets.map(function(m){{return Math.max(m.b,m.c);}}))*1.15||1;
var C1W=620,C1H=196,c1mt=38,c1mb=30,c1ml=56,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=54,c1gap=10;
var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
for(var gi=1;gi<=4;gi++){{
var gy=c1mt+c1ph*(1-gi/4),gv=maxV1*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"/>';
c1+='<text x="'+(c1ml-5)+'" y="'+(px(gy)+4)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="9" fill="#999">'+fmt2(gv)+'</text>';
}}
c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
c1+='<text x="'+(c1ml-5)+'" y="'+(c1mt+c1ph+4)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="9" fill="#999">0</text>';
c1mets.forEach(function(m,i){{
var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
c1+='<text x="'+cx+'" y="17" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="13" font-weight="700" fill="#444">'+esc(m.l)+'</text>';
c1+='<rect'+btt(m.l,'Scan 1: '+fmt2(m.b))+' x="'+c1x0+'" y="'+px(c1mt+c1ph-bh0)+'" width="'+c1bw+'" height="'+px(bh0)+'" fill="'+m.bc+'" rx="3" style="cursor:pointer;"/>';
c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+px(c1mt+c1ph-bh0-4)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" font-weight="600" fill="'+m.bc+'">'+fmt2(m.b)+'</text>';
c1+='<rect'+btt(m.l,'Latest (Scan '+N+'): '+fmt2(m.c))+' x="'+c1x1+'" y="'+px(c1mt+c1ph-bh1)+'" width="'+c1bw+'" height="'+px(bh1)+'" fill="'+m.cc+'" rx="3" style="cursor:pointer;"/>';
c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+px(c1mt+c1ph-bh1-4)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" font-weight="600" fill="'+m.cc+'">'+fmt2(m.c)+'</text>';
c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+(c1mt+c1ph+18)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="12" font-weight="500" fill="#888">Scan 1</text>';
c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+(c1mt+c1ph+18)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="12" font-weight="600" fill="'+m.cc+'">Latest</text>';
}});
c1+='</svg>';
// Chart 2: Delta by Metric (net delta first scan to last)
var mets=[
{{l:'Code Lines',v:Number(pLast.code)-Number(p0.code),mc:'#2563EB'}},
{{l:'Files Analyzed',v:Number(pLast.files)-Number(p0.files),mc:'#7C3AED'}},
{{l:'Comment Lines',v:Number(pLast.comments)-Number(p0.comments),mc:'#0D9488'}}
];
var maxD=Math.max.apply(null,mets.map(function(m){{return Math.abs(m.v);}}));maxD=maxD||1;
var C2W=530,rH=56,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;
var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
mets.forEach(function(m,i){{
var y=16+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+fmt2(m.v);
c2+='<text x="'+(c2LW-8)+'" y="'+(y+22)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="13" font-weight="600" fill="'+m.mc+'">'+esc(m.l)+'</text>';
c2+='<rect'+btt(m.l,'Net delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3" style="cursor:pointer;"/>';
if(bw>=52){{c2+='<text x="'+px(bx+bw/2)+'" y="'+(y+26)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="12" font-weight="700" fill="white">'+esc(vStr)+'</text>';}}
else{{var vx2=m.v>=0?px(bx+bw)+6:px(bx)-6,anc2=m.v>=0?'start':'end';c2+='<text x="'+vx2+'" y="'+(y+26)+'" text-anchor="'+anc2+'" font-family="Inter,Calibri,Arial" font-size="12" font-weight="700" fill="'+col+'">'+esc(vStr)+'</text>';}}
}});
c2+='</svg>';
// Chart 3: Language Code Delta (from FILES net total_code_delta per language)
var lm={{}};
FILES.forEach(function(f){{var l=f.l||'Unknown';if(!lm[l])lm[l]={{f:0,d:0}};lm[l].f++;lm[l].d+=f.t;}});
var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}}).slice(0,12);
var c3='';
if(langs.length){{
var maxLD=Math.max.apply(null,langs.map(function(l){{return Math.abs(lm[l].d);}}));maxLD=maxLD||1;
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;
c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
langs.forEach(function(l,i){{
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+fmt2(e.d);
c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
c3+='<rect'+btt(l,'Net delta: '+vStr+' • '+e.f+' file'+(e.f!==1?'s':''))+' x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="20" fill="'+col+'" rx="3"/>';
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>';}}
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>';}}
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>';
}});
c3+='</svg>';
}}
// Chart 4: File Change Distribution (centered donut, legend below)
var fm=0,fa=0,fr=0,fu=0;
FILES.forEach(function(f){{if(f.s==='modified')fm++;else if(f.s==='added')fa++;else if(f.s==='removed')fr++;else fu++;}});
var segs=[{{l:'Modified',v:fm,c:OX}},{{l:'Added',v:fa,c:GN}},{{l:'Removed',v:fr,c:RD}},{{l:'Unchanged',v:fu,c:'#CCCCCC'}}].filter(function(s){{return s.v>0;}});
var tot4=segs.reduce(function(a,s){{return a+s.v;}},0)||1;
var C4W=240,Ro=75,Ri=48,cx4=120,cy4=88,legY=172,legRowH=18,C4H=legY+Math.ceil(segs.length/2)*legRowH+8;
var c4='<svg viewBox="0 0 '+C4W+' '+C4H+'" width="100%" style="max-width:336px;display:block;margin:0 auto;" xmlns="http://www.w3.org/2000/svg">',ang4=-Math.PI/2;
if(segs.length===1){{
c4+='<circle'+btt(segs[0].l,fmt2(segs[0].v)+' files • 100%')+' cx="'+cx4+'" cy="'+cy4+'" r="'+Ro+'" fill="'+segs[0].c+'"/>';
c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="var(--surface-2)"/>';
}} else {{
segs.forEach(function(s){{
var sw=Math.min(s.v/tot4*2*Math.PI,2*Math.PI-0.001),a2=ang4+sw;
var x1=cx4+Ro*Math.cos(ang4),y1=cy4+Ro*Math.sin(ang4),x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2),xi2=cx4+Ri*Math.cos(ang4),yi2=cy4+Ri*Math.sin(ang4);
c4+='<path'+btt(s.l,fmt2(s.v)+' files • '+px(s.v/tot4*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"/>';
ang4+=sw;
}});
}}
c4+='<text x="'+cx4+'" y="'+(cy4-4)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="22" font-weight="bold" fill="#444">'+fmt2(tot4)+'</text>';
c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
segs.forEach(function(s,i){{
var col=i%2===0?14:C4W/2+6,row=Math.floor(i/2);
c4+='<rect'+btt(s.l,fmt2(s.v)+' files • '+px(s.v/tot4*100)+'%')+' x="'+col+'" y="'+(legY+row*legRowH)+'" width="12" height="12" fill="'+s.c+'" rx="2" style="cursor:pointer;"/>';
c4+='<text'+btt(s.l,fmt2(s.v)+' files • '+px(s.v/tot4*100)+'%')+' x="'+(col+16)+'" y="'+(legY+row*legRowH+10)+'" font-family="Inter,Calibri,Arial" font-size="11" fill="#555" style="cursor:pointer;">'+esc(s.l)+': '+fmt2(s.v)+'</text>';
}});
c4+='</svg>';
// Inject charts
var e1=document.getElementById('mc-ic-c1');if(e1){{e1.innerHTML=c1;addTT(e1);}}
var e2=document.getElementById('mc-ic-c2');if(e2){{e2.innerHTML=c2;addTT(e2);}}
var e3=document.getElementById('mc-ic-c3');if(e3){{e3.innerHTML=langs.length?c3:'<p style="color:var(--muted);font-size:13px;padding:8px 0 0;">No language delta.</p>';addTT(e3);}}
var e4=document.getElementById('mc-ic-c4');if(e4){{e4.innerHTML=c4;addTT(e4);}}
var lc=document.getElementById('mc-ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
// HTML legend hover → highlight matching SVG bars within the SAME card only
document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){{
var metric=leg.getAttribute('data-highlight');
var parentCard=leg.closest('.ic-card');
var chartEl=parentCard?parentCard.querySelector('[id]'):null;
if(!chartEl)return;
leg.addEventListener('mouseenter',function(){{
chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{
if(x.getAttribute('data-ttl').indexOf(metric)===0){{
x.style.filter='brightness(1.35) drop-shadow(0 2px 8px rgba(0,0,0,0.28))';
x.style.opacity='1';
}} else {{
x.style.opacity='0.28';
}}
}});
}});
leg.addEventListener('mouseleave',function(){{
chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
}});
}});
// Author handles
document.querySelectorAll('.cmp-author-val').forEach(function(el){{var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');}});
// ── Export helpers ────────────────────────────────────────────────────────
// Fetch one image from the server and return a data-URI Promise
function mcFetchUri(path){{
return fetch(path).then(function(r){{return r.blob();}}).then(function(b){{
return new Promise(function(res){{
var rd=new FileReader();rd.onload=function(){{res(rd.result);}};rd.onerror=function(){{res('');}};rd.readAsDataURL(b);
}});
}}).catch(function(){{return '';}});
}}
// Replace /images/… src attrs in html with base64 data-URIs (async, callback)
function mcInlineImgs(html,cb){{
var paths=[],seen={{}};
html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){{if(!seen[p]){{seen[p]=1;paths.push(p);}}return _;}});
if(!paths.length){{cb(html);return;}}
Promise.all(paths.map(function(p){{return mcFetchUri(p).then(function(u){{return{{p:p,u:u}};}}); }}))
.then(function(rs){{rs.forEach(function(r){{if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');}});cb(html);}})
.catch(function(){{cb(html);}});
}}
// Capture full-page HTML with all table rows visible
function mcRawHtml(pdfMode){{
if(pdfMode)document.body.classList.add('pdf-mode');
var s=perPage,p=currentPage;perPage=FILES.length||999999;currentPage=1;renderFilePage();
var html=document.documentElement.outerHTML;
perPage=s;currentPage=p;renderFilePage();
if(pdfMode)document.body.classList.remove('pdf-mode');
return html;
}}
// HTML export (full page with inlined images)
function mcDoHtml(btn,fname){{
var orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
mcInlineImgs(mcRawHtml(false),function(html){{
var blob=new Blob([html],{{type:'text/html;charset=utf-8;'}});
var a=document.createElement('a');a.href=URL.createObjectURL(blob);
a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
btn.disabled=false;btn.innerHTML=orig;
}});
}}
// PDF export — comprehensive document-style report: full numbers, all sections
function mcBuildPdfHtml(){{
function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
function full(n){{if(n==null||n===''||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
function dStr(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
function dHtml(v){{var s=dStr(v);return Number(v)>0?'<span style="color:#2a6846;font-weight:700">'+s+'</span>':Number(v)<0?'<span style="color:#b23030;font-weight:700">'+s+'</span>':'<span>'+s+'</span>';}}
var tz;try{{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{tz='America/Los_Angeles';}}
var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
function ptRef(pt,i){{return pt.tags||(pt.branch?(pt.commit?pt.branch+' @ '+pt.commit.slice(0,7):pt.branch):(pt.commit?pt.commit.slice(0,12):'Scan '+(i+1)));}}
var commitsList=POINTS.map(function(pt,i){{return esc(ptRef(pt,i));}}).join(', ');
var p0=N>0?POINTS[0]:null,pLast=N>0?POINTS[N-1]:null;
var codeDelta=(p0&&pLast)?Number(pLast.code)-Number(p0.code):null;
var css='body{{margin:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}}'+
'.hdr{{background:#1a2035;color:#fff;padding:16px 24px;display:flex;justify-content:space-between;align-items:flex-start;}}'+
'.brand{{font-size:13px;font-weight:800;color:#c45c10;letter-spacing:.06em;}}'+
'.title{{font-size:20px;font-weight:700;margin:3px 0 2px;line-height:1.2;}}'+
'.proj{{font-size:12px;color:#99aabb;margin-top:3px;}}'+
'.hr{{font-size:11px;color:#8899aa;text-align:right;line-height:1.9;}}'+
'.body{{padding:18px 24px;}}'+
'.sg{{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:18px;}}'+
'.sc{{border:1px solid #ddd;border-radius:8px;padding:10px 12px;}}'+
'.sv{{font-size:18px;font-weight:900;color:#c45c10;}}'+
'.sl{{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}}'+
'.sec{{margin-bottom:20px;}}'+
'.sh{{background:#1a2035;color:#fff;padding:5px 10px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;margin:0;}}'+
'table{{width:100%;border-collapse:collapse;font-size:11px;}}'+
'th{{background:#1a2035;color:#fff;padding:5px 8px;font-size:10px;font-weight:700;text-align:left;letter-spacing:.04em;white-space:nowrap;}}'+
'td{{border-bottom:1px solid #eee;padding:4px 8px;vertical-align:middle;}}'+
'tr:nth-child(even) td{{background:#faf8f6;}}'+
'.ftr{{background:#1a2035;color:#7a8b9c;font-size:10px;padding:7px 24px;display:flex;justify-content:space-between;margin-top:20px;}}';
// ── Metric Progression ────────────────────────────────────────────────
var hasTests=POINTS.some(function(pt){{return pt.tests!=null&&Number(pt.tests)>0;}});
var hasCov=POINTS.some(function(pt){{return pt.cov!=null;}});
var progHdr='<th>#</th><th>Scan Ref</th><th style="text-align:right">Code Lines</th><th style="text-align:right">Comments</th><th style="text-align:right">Blank Lines</th><th style="text-align:right">Files</th>';
if(hasTests)progHdr+='<th style="text-align:right">Tests</th>';
if(hasCov)progHdr+='<th style="text-align:right">Coverage</th>';
var progRows=POINTS.map(function(pt,i){{
var lbl=pt.tags||(pt.branch?(pt.commit?pt.branch+' @ '+pt.commit.slice(0,8):pt.branch):(pt.commit?pt.commit.slice(0,12):'Scan '+(i+1)));
var r='<tr><td style="text-align:center;font-weight:700">'+(i+1)+'</td><td>'+esc(lbl)+'</td>'+
'<td style="text-align:right">'+full(pt.code)+'</td>'+
'<td style="text-align:right">'+full(pt.comments)+'</td>'+
'<td style="text-align:right">'+full(pt.blank)+'</td>'+
'<td style="text-align:right">'+full(pt.files)+'</td>';
if(hasTests)r+='<td style="text-align:right">'+(pt.tests!=null&&Number(pt.tests)>0?full(pt.tests):'—')+'</td>';
if(hasCov)r+='<td style="text-align:right">'+(pt.cov!=null?Number(pt.cov).toFixed(1)+'%':'—')+'</td>';
return r+'</tr>';
}}).join('');
// ── Scan-to-scan changes ──────────────────────────────────────────────
var deltaRows=N>1?POINTS.slice(1).map(function(pt,i){{
var prev=POINTS[i];
var cd=Number(pt.code)-Number(prev.code),cm=Number(pt.comments)-Number(prev.comments);
var bl=Number(pt.blank)-Number(prev.blank),fd=Number(pt.files)-Number(prev.files);
return '<tr><td style="font-weight:700;white-space:nowrap">'+esc(ptRef(prev,i))+' \u2192 '+esc(ptRef(pt,i+1))+'</td>'+
'<td style="text-align:right">'+dHtml(cd)+'</td>'+
'<td style="text-align:right">'+dHtml(cm)+'</td>'+
'<td style="text-align:right">'+dHtml(bl)+'</td>'+
'<td style="text-align:right">'+dHtml(fd)+'</td></tr>';
}}).join(''):'';
// ── File matrix (top 50 by |total delta|) ────────────────────────────
var fmSection='';
if(FILES&&FILES.length){{
// Hard cap on per-scan columns so the table never overflows the page width.
var MAXC=6;var startIdx=N>MAXC?N-MAXC:0;
var topFiles=FILES.slice().sort(function(a,b){{return Math.abs(Number(b.t))-Math.abs(Number(a.t));}});
var fmHdr='<th>File</th><th>Language</th><th>Status</th>';
for(var fi=startIdx;fi<N;fi++)fmHdr+='<th style="text-align:right">Scan '+(fi+1)+'</th>';
fmHdr+='<th style="text-align:right">Total \u0394</th>';
var fmRows=topFiles.map(function(f){{
var ss=f.s==='added'?'style="color:#2a6846;font-weight:700"':f.s==='removed'?'style="color:#b23030;font-weight:700"':'';
var cols='';for(var fi=startIdx;fi<N;fi++)cols+='<td style="text-align:right">'+(f.c[fi]!=null?Number(f.c[fi]).toLocaleString():'—')+'</td>';
cols+='<td style="text-align:right">'+dHtml(Number(f.t))+'</td>';
var sp=f.p.length>55?'\u2026'+f.p.slice(-53):f.p;
return '<tr><td style="font-family:monospace;font-size:10px;word-break:break-all">'+esc(sp)+'</td><td>'+esc(f.l||'')+'</td><td '+ss+'>'+esc(f.s||'')+'</td>'+cols+'</tr>';
}}).join('');
var colNote=N>MAXC?' (latest '+MAXC+' scans shown)':'';
fmSection='<div class="sec"><p class="sh">File Matrix \u2014 All '+FILES.length+' Files'+colNote+'</p>'+
'<table><thead><tr>'+fmHdr+'</tr></thead><tbody>'+fmRows+'</tbody></table></div>';
}}
return '<!DOCTYPE html><html><head><meta charset="utf-8">'+
'<title>OxideSLOC \u2014 Multi-Scan Timeline</title><style>'+css+'</style></head><body>'+
'<div class="hdr"><div><div class="brand">oxide-sloc</div><div class="title">Multi-Scan Timeline</div><div class="proj">{project_label}</div></div>'+
'<div class="hr">{n} scans<br><span style="color:#7a8b9c">'+commitsList+'</span><br>Generated: '+esc(now)+'</div></div>'+
'<div class="body">'+
'<div class="sg">'+
(pLast?'<div class="sc"><div class="sv">'+full(pLast.code)+'</div><div class="sl">Latest Code Lines</div></div>':
'<div class="sc"><div class="sv">—</div><div class="sl">Latest Code Lines</div></div>')+
(pLast?'<div class="sc"><div class="sv">'+full(pLast.files)+'</div><div class="sl">Latest Files</div></div>':
'<div class="sc"><div class="sv">—</div><div class="sl">Latest Files</div></div>')+
(codeDelta!==null?'<div class="sc"><div class="sv" style="'+(codeDelta>0?'color:#2a6846':codeDelta<0?'color:#b23030':'color:#555')+';font-weight:900">'+dStr(codeDelta)+'</div><div class="sl">Net Code Change</div></div>':
'<div class="sc"><div class="sv">—</div><div class="sl">Net Code Change</div></div>')+
'<div class="sc"><div class="sv" style="color:#111">{n}</div><div class="sl">Scans Compared</div></div>'+
'</div>'+
'<div class="sec"><p class="sh">Metric Progression</p>'+
'<table><thead><tr>'+progHdr+'</tr></thead><tbody>'+progRows+'</tbody></table></div>'+
(N>1?'<div class="sec"><p class="sh">Scan-to-Scan Changes</p>'+
'<table><thead><tr><th style="text-align:center">Scans</th>'+
'<th style="text-align:right">Code \u0394</th><th style="text-align:right">Comments \u0394</th>'+
'<th style="text-align:right">Blank \u0394</th><th style="text-align:right">Files \u0394</th>'+
'</tr></thead><tbody>'+deltaRows+'</tbody></table></div>':'')+
fmSection+
'</div>'+
'<div class="ftr"><span>oxide-sloc v{version}</span><span>Multi-Scan Timeline Report</span><span>{project_label} · {n} scans</span></div>'+
'</body></html>';
}}
function mcDoPdf(btn){{
var orig=btn.innerHTML;btn.disabled=true;btn.textContent='Generating PDF\u2026';
var html=mcBuildPdfHtml();
fetch('/export/pdf',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{html:html,filename:mcExportName('pdf')}})}})
.then(function(r){{if(!r.ok)throw new Error('PDF failed: '+r.status);return r.blob();}})
.then(function(blob){{var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=mcExportName('pdf');a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);}})
.catch(function(e){{alert('PDF export failed: '+e.message);}})
.finally(function(){{btn.disabled=false;btn.innerHTML=orig;}});
}}
var mcHtmlBtn=document.getElementById('mc-export-html-btn');
if(mcHtmlBtn)mcHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcHtmlBtn,mcExportName('html'));}});
var mcTopHtmlBtn=document.getElementById('mc-top-export-html-btn');
if(mcTopHtmlBtn)mcTopHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcTopHtmlBtn,mcExportName('html'));}});
var mcPdfBtn=document.getElementById('mc-export-pdf-btn');
if(mcPdfBtn)mcPdfBtn.addEventListener('click',function(){{mcDoPdf(mcPdfBtn);}});
var mcTopPdfBtn=document.getElementById('mc-top-export-pdf-btn');
if(mcTopPdfBtn)mcTopPdfBtn.addEventListener('click',function(){{mcDoPdf(mcTopPdfBtn);}});
if(location.protocol==='file:'){{
[mcHtmlBtn,mcTopHtmlBtn,document.getElementById('mc-file-html-btn')].forEach(function(b){{if(b){{b.disabled=true;b.style.opacity='0.45';b.style.cursor='not-allowed';b.title='Already viewing an exported HTML file';b.textContent='Export HTML';}}}} );
[mcPdfBtn,mcTopPdfBtn,document.getElementById('mc-file-pdf-btn')].forEach(function(b){{if(b){{b.disabled=true;b.style.opacity='0.45';b.style.cursor='not-allowed';b.title='PDF export requires a running server';b.textContent='Export PDF';}}}} );
}}
}})();
// ── Scan card modal — document-level click delegation (no timing/parse-order deps) ──
(function(){{
function $(id){{return document.getElementById(id);}}
function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
function full(n){{if(n==null||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
function dS(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
function dSt(v){{return Number(v)>0?'color:#2a6846;font-weight:700':Number(v)<0?'color:#b23030;font-weight:700':'';}}
function openModal(idx){{
var ov=$('mc-modal-overlay');if(!ov)return;
var titleEl=$('mc-modal-title'),subEl=$('mc-modal-sub'),bodyEl=$('mc-modal-body');
if(idx<0||idx>=N)return;
var pt=POINTS[idx];
titleEl.textContent='Scan '+(idx+1);
var lbl=pt.tags||(pt.branch?(pt.commit?pt.branch+' @ '+pt.commit:pt.branch):(pt.commit||'\u2014'));
subEl.textContent=lbl;
var sHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Metrics</div><div class="mc-modal-stats">'+
'<div class="mc-modal-stat" data-tip="Physical lines of source code that are neither blank nor comment-only. This is the primary SLOC metric used to size the codebase."><div class="mc-modal-stat-val">'+full(pt.code)+'</div><div class="mc-modal-stat-lbl">Code Lines</div></div>'+
'<div class="mc-modal-stat" data-tip="Lines made up of code comments (single-line or block). Documentation within the source that is not executed."><div class="mc-modal-stat-val">'+full(pt.comments)+'</div><div class="mc-modal-stat-lbl">Comments</div></div>'+
'<div class="mc-modal-stat" data-tip="Empty lines or lines containing only whitespace. Counted separately from code and comment lines."><div class="mc-modal-stat-val">'+full(pt.blank)+'</div><div class="mc-modal-stat-lbl">Blank Lines</div></div>'+
'<div class="mc-modal-stat" data-tip="Total number of source files analyzed in this scan across every supported language."><div class="mc-modal-stat-val">'+full(pt.files)+'</div><div class="mc-modal-stat-lbl">Files</div></div>'+
(pt.tests!=null&&Number(pt.tests)>0?'<div class="mc-modal-stat" data-tip="Number of unit-test definitions detected across the scanned files."><div class="mc-modal-stat-val">'+full(pt.tests)+'</div><div class="mc-modal-stat-lbl">Tests</div></div>':'')+
(pt.cov!=null?'<div class="mc-modal-stat" data-tip="Percentage of code lines covered by tests for this scan, shown when coverage results were captured."><div class="mc-modal-stat-val">'+Number(pt.cov).toFixed(1)+'%</div><div class="mc-modal-stat-lbl">Coverage</div></div>':'')+
'</div></div>';
var iHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Scan Info</div>'+
(pt.commit?'<div class="mc-modal-row"><span class="mc-modal-key">Commit</span><span class="mc-modal-val"><a href="/runs/html/'+esc(pt.run_id)+'" target="_blank" rel="noopener">'+esc(pt.commit)+'</a></span></div>':'')+
(pt.branch?'<div class="mc-modal-row"><span class="mc-modal-key">Branch</span><span class="mc-modal-val">'+esc(pt.branch)+'</span></div>':'')+
(pt.tags?'<div class="mc-modal-row"><span class="mc-modal-key">Tags</span><span class="mc-modal-val">'+esc(pt.tags)+'</span></div>':'')+
(pt.nearest?'<div class="mc-modal-row"><span class="mc-modal-key">Nearest tag</span><span class="mc-modal-val">'+esc(pt.nearest)+'</span></div>':'')+
(pt.commit_date?'<div class="mc-modal-row"><span class="mc-modal-key">Last commit on</span><span class="mc-modal-val">'+esc(pt.commit_date)+'</span></div>':'')+
(pt.author?'<div class="mc-modal-row"><span class="mc-modal-key">Last commit by</span><span class="mc-modal-val">'+esc(pt.author)+'</span></div>':'')+
(pt.scanned?'<div class="mc-modal-row"><span class="mc-modal-key">Scanned on</span><span class="mc-modal-val">'+esc(pt.scanned)+'</span></div>':'')+
'<div class="mc-modal-row"><span class="mc-modal-key">Run ID</span><span class="mc-modal-val"><a href="/runs/html/'+esc(pt.run_id)+'" target="_blank" rel="noopener">'+esc(pt.run_id)+'</a></span></div>'+
'</div>';
var dHtml='';
if(idx>0){{
var prev=POINTS[idx-1];
var cd=Number(pt.code)-Number(prev.code),fd=Number(pt.files)-Number(prev.files),cm=Number(pt.comments)-Number(prev.comments);
dHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Change vs Scan '+idx+'</div><div class="mc-modal-stats">'+
'<div class="mc-modal-stat" data-tip="Net change in code lines compared with the previous scan in this timeline. Green is an increase, red a decrease."><div class="mc-modal-stat-val" style="'+dSt(cd)+'">'+dS(cd)+'</div><div class="mc-modal-stat-lbl">Code \u0394</div></div>'+
'<div class="mc-modal-stat" data-tip="Net change in the number of analyzed files compared with the previous scan."><div class="mc-modal-stat-val" style="'+dSt(fd)+'">'+dS(fd)+'</div><div class="mc-modal-stat-lbl">Files \u0394</div></div>'+
'<div class="mc-modal-stat" data-tip="Net change in comment lines compared with the previous scan."><div class="mc-modal-stat-val" style="'+dSt(cm)+'">'+dS(cm)+'</div><div class="mc-modal-stat-lbl">Comments \u0394</div></div>'+
'</div></div>';
}}
bodyEl.innerHTML=sHtml+iHtml+dHtml;
ov.classList.add('open');document.body.style.overflow='hidden';
}}
function closeModal(){{var ov=$('mc-modal-overlay');if(ov)ov.classList.remove('open');document.body.style.overflow='';}}
// Delegated click: robust to parse order, re-renders, and missing-at-attach elements.
document.addEventListener('click',function(e){{
if(!e.target||!e.target.closest)return;
if(e.target.closest('#mc-modal-close')){{closeModal();return;}}
if(e.target.id==='mc-modal-overlay'){{closeModal();return;}}
var card=e.target.closest('.mc-card');
if(!card)return;
if(e.target.closest('a'))return;
var cards=Array.prototype.slice.call(document.querySelectorAll('.mc-card'));
var i=cards.indexOf(card);
if(i>=0)openModal(i);
}});
document.addEventListener('keydown',function(e){{if(e.key==='Escape')closeModal();}});
// Styled hover description for the metric boxes (fixed tooltip, never clipped by the modal scroll area).
var statTip=null;
document.addEventListener('mousemove',function(e){{
var box=(e.target&&e.target.closest)?e.target.closest('.mc-modal-stat[data-tip]'):null;
if(!box){{if(statTip)statTip.style.display='none';return;}}
if(!statTip){{statTip=document.createElement('div');statTip.id='mc-stat-tt';document.body.appendChild(statTip);}}
var tip=box.getAttribute('data-tip')||'';
if(statTip.textContent!==tip)statTip.textContent=tip;
statTip.style.display='block';
var w=statTip.offsetWidth,h=statTip.offsetHeight,x=e.clientX+14,y=e.clientY+16;
if(x+w>window.innerWidth-8)x=e.clientX-w-14;
if(y+h>window.innerHeight-8)y=e.clientY-h-16;
statTip.style.left=(x<8?8:x)+'px';statTip.style.top=(y<8?8:y)+'px';
}});
(function tagCards(){{var cs=document.querySelectorAll('.mc-card');for(var k=0;k<cs.length;k++)cs[k].setAttribute('title','Click to view full scan details');}})();
}})();
}})();
</script>
<script nonce="{csp_nonce}">(function(){{var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';
if(location.protocol==='file:'){{if(lbl)lbl.textContent='Offline';if(dot){{dot.style.background='#888';dot.style.boxShadow='none';}}if(pingEl)pingEl.textContent='';var td=document.querySelector('.server-status-tip');if(td)td.textContent='Saved HTML report \u2014 server not connected.';return;}}
if(lbl)lbl.textContent=isServer?'Server':'Local';function setDot(ms){{if(!dot)return;if(ms<100){{dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}}else if(ms<300){{dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}}else{{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}}function doPing(){{var t0=performance.now();fetch('/healthz',{{cache:'no-store'}}).then(function(){{var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}}).catch(function(){{if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}});}}doPing();setInterval(doPing,5000);}})();</script>
<!-- Scan card detail modal -->
<div class="mc-modal-overlay" id="mc-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="mc-modal-title">
<div class="mc-modal" id="mc-modal">
<div class="mc-modal-head">
<div><div class="mc-modal-title" id="mc-modal-title">Scan</div><div class="mc-modal-sub" id="mc-modal-sub"></div></div>
<button class="mc-modal-close" id="mc-modal-close" aria-label="Close">✕</button>
</div>
<div class="mc-modal-body" id="mc-modal-body"></div>
</div>
</div>
</body>
</html>"##,
project_label = html_escape(project_label),
n = n,
scan_strip = scan_strip,
mc_strip_class = mc_strip_class,
metrics_thead = metrics_thead,
metrics_tbody = metrics_tbody,
file_col_headers = file_col_headers,
total_files = total_files,
files_modified = files_modified,
files_added = files_added,
files_removed = files_removed,
files_unchanged = files_unchanged,
points_json = points_json,
file_matrix_json = file_matrix_json,
nav_compare_active = nav_compare_active,
version = version,
csp_nonce = csp_nonce,
scope_bar_html = scope_bar_html,
scope_label = scope_label,
)
}
// ── Trend report page ─────────────────────────────────────────────────────────
// Protected. Interactive time-series chart page that loads scan history via
// /api/metrics/history and renders a vanilla-SVG line chart.
//
// GET /trend-reports
#[allow(clippy::too_many_lines)] // trend report page with inline HTML; splitting would fragment the template
async fn trend_report_handler(
State(state): State<AppState>,
axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
) -> Response {
auto_scan_watched_dirs(&state).await;
let watched_dirs_list: Vec<String> = {
let wd = state.watched_dirs.lock().await;
wd.dirs.iter().map(|p| p.display().to_string()).collect()
};
// Collect distinct project roots for the root selector dropdown.
let roots: Vec<String> = {
let reg = state.registry.lock().await;
let mut seen = std::collections::BTreeSet::new();
reg.entries
.iter()
.flat_map(|e| e.input_roots.iter().cloned())
.filter(|r| seen.insert(r.clone()))
.collect()
};
let roots_json = serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_string());
let nonce = &csp_nonce;
let version = env!("CARGO_PKG_VERSION");
// Build the watched-dirs bar HTML (outside the format! so braces don't need escaping).
// Build the watched-dirs bar HTML. In Network Server mode show a locked notice instead
// of interactive controls — folder watching is managed by the host administrator.
let watched_dirs_html: String = if state.server_mode {
r#"<div class="watched-bar"><div class="watched-bar-left"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg><span class="watched-label">Watched Folders</span><div class="watched-chips"><span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span></div></div></div>"#.to_string()
} else {
let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
.to_string()
} else {
watched_dirs_list
.iter()
.fold(String::new(), |mut s, d| {
use std::fmt::Write as _;
let escaped =
d.replace('&', "&").replace('"', """).replace('<', "<");
write!(
s,
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>"#
).expect("write to String is infallible");
s
})
};
format!(
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>"#
)
};
let html = format!(
r##"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OxideSLOC | Trend Reports</title>
<link rel="icon" type="image/png" href="/images/logo/small-logo.png">
<style nonce="{nonce}">
:root {{
--radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
--line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
--nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
--oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
--info-bg:#eef3ff; --info-text:#4467d8;
}}
body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
*{{box-sizing:border-box;}} html,body{{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);}} body{{display:flex;flex-direction:column;}}
.background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
.background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
.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;}}
@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));}}}}
.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);}}
.top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
.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));}}
.brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
.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;}}
.nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
@media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
@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; }} }}
.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;}}
.nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
.theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
.theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
.theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
.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;}}
.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;}}
.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;}}
.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;}}
.settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
.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);}}
.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;}}
.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;}}
.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;}}
.scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
.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;}}
.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);}}
.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;}}
.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;}}
.tz-select:focus{{border-color:var(--oxide);}}
.page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
@media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
.panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
.muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
.trend-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px;}}
.trend-title-block{{flex:1;min-width:0;}}
.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;}}
.controls-centered label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
.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;}}
.chart-select:focus{{border-color:var(--accent);}}
.summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
@media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
.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;}}
.stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
.stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
.stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
.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.6;white-space:normal;max-width:280px;pointer-events:none;opacity:0;transition:opacity .2s ease;z-index:200;box-shadow:0 4px 14px rgba(0,0,0,0.2);}}
.stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
.stat-chip:hover .stat-chip-tip{{opacity:1;}}
.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;}}
.stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}
body.dark-theme .stat-delta-up{{color:#5aba8a;}}body.dark-theme .stat-delta-down{{color:#e07070;}}
.chart-wrap{{width:100%;overflow-x:auto;}} .chart-wrap svg{{display:block;margin:0 auto;}}
.empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
.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;}}
.chart-hint-inline svg{{width:12px;height:12px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}}
.chart-hint-inline .dot{{display:inline-block;width:8px;height:8px;border-radius:50%;vertical-align:middle;margin:0 1px;}}
.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);}}
.data-table{{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}}
.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;}}
.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;}}
.data-table tr:last-child td{{border-bottom:none;}}
.data-table tbody tr:hover td{{background:var(--surface-2);cursor:pointer;}}
.num{{text-align:right;font-variant-numeric:tabular-nums;}}
.table-wrap{{width:100%;overflow-x:auto;}}
.data-table th.sortable{{cursor:pointer;}} .data-table th.sortable:hover{{color:var(--oxide);}}
.sort-icon{{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}}
.data-table th.sort-asc .sort-icon,.data-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
.col-resize-handle{{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}}
.col-resize-handle:hover,.col-resize-handle.dragging{{background:rgba(211,122,76,0.3);}}
.filter-row{{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}}
.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;}}
.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;}}
.pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
.pagination-info{{font-size:13px;color:var(--muted);}}
.pagination-btns{{display:flex;gap:6px;}}
.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;}}
.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;}}
#scan-history-table col:nth-child(1){{width:155px;}}
#scan-history-table col:nth-child(2){{width:240px;}}
#scan-history-table col:nth-child(3){{width:82px;}}
#scan-history-table col:nth-child(4){{width:82px;}}
#scan-history-table col:nth-child(5){{width:90px;}}
#scan-history-table col:nth-child(6){{width:90px;}}
#scan-history-table col:nth-child(7){{width:88px;}}
#scan-history-table col:nth-child(8){{width:150px;}}
#scan-history-table td:nth-child(8){{overflow:visible!important;white-space:normal!important;}}
.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;}}
.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;}}
.toolbar-divider{{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}}
.toolbar-right{{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}}
.watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
.watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
.watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
.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;}}
.watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
.watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
.watched-chip-rm:hover{{color:var(--oxide);}}
.watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
.watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
.watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
.mono{{font-family:ui-monospace,monospace;font-size:11px;}}
a.run-link{{color:var(--accent-2);font-weight:700;text-decoration:none;}}
a.run-link:hover{{text-decoration:underline;}}
.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);}}
.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);}}
body.dark-theme .git-chip{{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}}
.metric-num{{font-weight:700;color:var(--text);}}
.metric-secondary{{font-size:11px;color:var(--muted);margin-top:2px;}}
.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;}}
.btn.primary{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
.btn.primary:hover{{opacity:.9;}}
.rpt-btn{{min-width:58px;justify-content:center;}}
.actions-cell{{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}}
.report-cell{{overflow:visible!important;white-space:normal!important;}}
.submod-details{{margin-top:6px;font-size:12px;color:var(--muted);}}
.submod-details summary{{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}}
.submod-details summary::-webkit-details-marker{{display:none;}}
.submod-link-list{{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}}
.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;}}
.submod-view-btn:hover{{background:rgba(111,155,255,0.22);}}
body.dark-theme .submod-view-btn{{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}}
.chart-actions{{display:flex;justify-content:flex-end;gap:7px;margin-bottom:10px;}}
.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;}}
.export-btn:hover{{background:var(--line);}}
.export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
.site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
.site-footer a{{color:var(--muted);}}
.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;}}
.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;}}
@keyframes spin-load{{to{{transform:rotate(360deg);}}}}
</style>
</head>
<body>
<div class="background-watermarks" aria-hidden="true">
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
</div>
<div class="code-particles" id="code-particles" aria-hidden="true"></div>
<div class="top-nav">
<div class="top-nav-inner">
<a class="brand" href="/">
<img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
<div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Trend report</div></div>
</a>
<div class="nav-right">
<a class="nav-pill" href="/">Home</a>
<div class="nav-dropdown">
<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>
<div class="nav-dropdown-menu">
<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>
</div>
</div>
<a class="nav-pill" href="/compare-scans">Compare Scans</a>
<a class="nav-pill" href="/test-metrics">Test Metrics</a>
<div class="nav-dropdown">
<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>
<div class="nav-dropdown-menu">
<a href="/integrations"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
</div>
</div>
<div class="server-status-wrap" id="server-status-wrap">
<div class="nav-pill server-online-pill" id="server-status-pill">
<span class="status-dot" id="status-dot"></span>
<span id="server-status-label">Server</span>
<span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
</div>
<div class="server-status-tip">
OxideSLOC is running — accessible on your network.
<span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
</div>
</div>
<button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
<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>
</button>
<button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
<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>
<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>
</button>
</div>
</div>
</div>
<div class="page">
{watched_dirs_html}
<div class="summary-strip" id="trend-stats"></div>
<div class="panel">
<div class="trend-header">
<div class="trend-title-block">
<h1>Trend Reports</h1>
<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>
<span class="chart-hint-inline">
<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>
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
</span>
</div>
<div class="chart-actions">
<button type="button" class="export-btn" id="retention-policy-btn" title="Configure automatic cleanup of old scan runs">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
Retention Policy
</button>
<button type="button" class="export-btn" id="cleanup-runs-btn" title="Delete scans older than a chosen number of days">
<svg viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>
Clean up old runs
</button>
<button type="button" class="export-btn" id="export-xlsx-btn" title="Download scan history as Excel workbook (.xlsx)">
<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>
Export Excel
</button>
<button type="button" class="export-btn" id="export-png-btn" title="Save chart as PNG image">
<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>
Export PNG
</button>
</div>
</div>
<div class="controls-centered">
<label>Project Root:
<select class="chart-select" id="root-sel">
<option value="">All projects</option>
</select>
</label>
<label>Y Metric:
<select class="chart-select" id="y-sel">
<option value="code_lines">Code Lines</option>
<option value="comment_lines">Comment Lines</option>
<option value="blank_lines">Blank Lines</option>
<option value="physical_lines">Physical Lines</option>
<option value="files_analyzed">Files Analyzed</option>
</select>
</label>
<label>X Axis:
<select class="chart-select" id="x-sel">
<option value="time">By Time</option>
<option value="commit">By Commit</option>
<option value="release">By Release</option>
<option value="tag">Tagged Commits</option>
</select>
</label>
<label id="submodule-label" style="display:none;">Submodule:
<select class="chart-select" id="sub-sel">
<option value="">All (project total)</option>
</select>
</label>
<label>Chart Size:
<select class="chart-select" id="scale-sel">
<option value="0.75">Compact</option>
<option value="1.2" selected>Normal</option>
<option value="1.38">Large</option>
</select>
</label>
</div>
<div id="chart-wrap" class="chart-wrap"><div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div></div>
<div id="data-table-wrap" style="overflow-x:auto;"></div>
</div>
</div>
<script nonce="{nonce}">
(function() {{
// Theme persistence
var b = document.body;
try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
var tgl = document.getElementById('theme-toggle');
if (tgl) tgl.addEventListener('click', function() {{
var d = b.classList.toggle('dark-theme');
try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
}});
// Watermark randomizer
(function() {{
var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
if (!wms.length) return;
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),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;}});
}})();
// Code particles
(function() {{
var container = document.getElementById('code-particles');
if (!container) return;
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'];
for (var i = 0; i < 38; i++) {{
(function(idx) {{
var el = document.createElement('span');
el.className = 'code-particle';
el.textContent = snippets[idx % snippets.length];
var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
var rot = (Math.random() * 26 - 13).toFixed(1), 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);
}}
}})();
// Watched folder picker
(function() {{
var btn = document.getElementById('add-watched-btn');
if (!btn) return;
btn.addEventListener('click', function() {{
fetch('/pick-directory?kind=reports')
.then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
.then(function(data) {{
if (!data.cancelled && data.selected_path) {{
var form = document.createElement('form');
form.method = 'POST';
form.action = '/watched-dirs/add';
var ri = document.createElement('input');
ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
var fi = document.createElement('input');
fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
form.appendChild(ri); form.appendChild(fi);
document.body.appendChild(form);
form.submit();
}}
}})
.catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
}});
}})();
// Settings / color-scheme modal
(function() {{
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'}}];
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);}});}}
try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
var btn=document.getElementById('settings-btn');if(!btn)return;
var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
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>';
document.body.appendChild(m);
var g=document.getElementById('scheme-grid');
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);}});
var cl=document.getElementById('settings-close');
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);
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');}});
if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
}})();
}})();
var ROOTS = {roots_json};
var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
var COLS = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E'];
var allData = [];
// Populate root selector
var rootSel = document.getElementById('root-sel');
ROOTS.forEach(function(r){{ var o=document.createElement('option');o.value=r;o.textContent=r;rootSel.appendChild(o); }});
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(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}}
function fmtFull(n){{return Number(n).toLocaleString();}}
function esc(s){{ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }}
// Tooltip
var tt = document.createElement('div');
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);';
document.body.appendChild(tt);
function showTT(e,html){{tt.innerHTML=html;tt.style.display='block';moveTT(e);}}
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';}}
function hideTT(){{tt.style.display='none';}}
window.addEventListener('blur',function(){{hideTT();}});
document.addEventListener('visibilitychange',function(){{if(document.hidden)hideTT();}});
function statExact(compact, full){{
return compact!==full?'<span class="stat-chip-exact">'+full+'</span>':'';
}}
function statVal(n){{
var compact=fmt(n),full=fmtFull(n);return compact+statExact(compact,full);
}}
function updateStats(data){{
var statsEl=document.getElementById('trend-stats');
if(!statsEl)return;
if(!data||!data.length){{statsEl.innerHTML='';return;}}
var yKey=document.getElementById('y-sel').value;
var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
var sorted=data.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
var firstVal=Number(sorted[0][yKey])||0,lastVal=Number(sorted[sorted.length-1][yKey])||0;
var delta=lastVal-firstVal,sign=delta>=0?'+':'',cls=delta>=0?'stat-delta-up':'stat-delta-down';
var absDelta=Math.abs(delta);
var deltaCompact=fmt(absDelta),deltaFull=fmtFull(absDelta);
var deltaExact=statExact(deltaCompact,deltaFull);
var projs={{}};data.forEach(function(d){{projs[d.project_label]=1;}});
statsEl.innerHTML=
'<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>'+
'<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>'+
'<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>'+
'<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>';
}}
var subSel = document.getElementById('sub-sel');
var subLabel = document.getElementById('submodule-label');
function populateSubmodules(root){{
if(!subSel||!subLabel)return;
while(subSel.options.length>1)subSel.remove(1);
subSel.value='';
var url='/api/metrics/submodules'+(root?'?root='+encodeURIComponent(root):'');
fetch(url)
.then(function(r){{return r.json();}})
.then(function(subs){{
if(!subs||!subs.length){{subLabel.style.display='none';return;}}
subs.forEach(function(s){{
var o=document.createElement('option');
o.value=s.name;
o.textContent=s.name+(s.relative_path&&s.relative_path!==s.name?' ('+s.relative_path+')':'');
subSel.appendChild(o);
}});
subLabel.style.display='';
}})
.catch(function(){{subLabel.style.display='none';}});
}}
var LOADING_HTML='<div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div>';
function loadAndRender(){{
var root = rootSel.value;
var sub = subSel ? subSel.value : '';
document.getElementById('chart-wrap').innerHTML=LOADING_HTML;
document.getElementById('data-table-wrap').innerHTML='';
var url = '/api/metrics/history?limit=100'
+ (root ? '&root='+encodeURIComponent(root) : '')
+ (sub ? '&submodule='+encodeURIComponent(sub) : '');
fetch(url).then(function(r){{return r.json();}}).then(function(data){{
allData = data;
render(data);
updateStats(data);
}}).catch(function(){{
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>';
}});
}}
function render(data){{
var yKey = document.getElementById('y-sel').value;
var xMode = document.getElementById('x-sel').value;
// Filter for tag/release mode
var pts = data;
if(xMode === 'tag') pts = data.filter(function(d){{return d.tags&&d.tags.length>0;}});
// Sort oldest-first for the line chart
pts = pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
var wrap = document.getElementById('chart-wrap');
if(!pts.length){{
var emptyMsg = (xMode === 'tag')
? 'No scans found at exact tagged commits. Try <strong>By Release</strong> to see all scans labelled by their nearest ancestor release tag.'
: 'No scan data found for the selected filters.';
wrap.innerHTML='<div class="empty-state">'+emptyMsg+'</div>';
renderTable([]);
return;
}}
var scaleEl=document.getElementById('scale-sel');
var sc=scaleEl?parseFloat(scaleEl.value)||1:1;
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;
var maxY = Math.max.apply(null,pts.map(function(d){{return Number(d[yKey])||0;}}))||1;
var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
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">';
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>';
var fs=Math.round(10*sc),fsS=Math.round(9*sc),fsL=Math.round(11*sc);
// Grid + Y axis ticks
for(var ti=0;ti<=5;ti++){{
var gy=PT+CH-Math.round(ti/5*CH);
var gv=Math.round(ti/5*maxY);
svg+='<line x1="'+PL+'" y1="'+gy+'" x2="'+(PL+CW)+'" y2="'+gy+'" stroke="#e6d0bf" stroke-width="1"/>';
svg+='<text x="'+(PL-6)+'" y="'+(gy+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="'+fs+'" fill="#7b675b">'+fmt(gv)+'</text>';
}}
// X axis labels (every N-th point to avoid crowding)
var labelEvery=Math.max(1,Math.ceil(pts.length/10));
pts.forEach(function(d,i){{
var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
if(i%labelEvery===0||i===pts.length-1){{
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)));
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>';
}}
}});
// Axis label
var xAxisLabel=xMode==='time'?'Scan Date':(xMode==='commit'?'Commit':(xMode==='release'?'Release':'Tag'));
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>';
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>';
// Area fill + line path
var pathD='';
pts.forEach(function(d,i){{
var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
pathD+=(i===0?'M':'L')+x+','+y;
}});
if(pts.length>1){{
var x0=PL,xN=PL+Math.round((pts.length-1)/(Math.max(pts.length-1,1))*CW);
svg+='<path d="M'+x0+','+(PT+CH)+' '+pathD.substring(1)+' L'+xN+','+(PT+CH)+'Z" fill="url(#areaFill)" pointer-events="none"/>';
}}
svg+='<path d="'+pathD+'" fill="none" stroke="#C45C10" stroke-width="'+(2+sc)+'" stroke-linejoin="round" stroke-linecap="round"/>';
// Data points (clickable) + permanent value labels
var showLabels = pts.length <= 40;
var labelEveryN = pts.length > 20 ? 2 : 1;
pts.forEach(function(d,i){{
var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
var hasTags=d.tags&&d.tags.length>0;
var isReleasePoint=hasTags||(xMode==='release'&&d.nearest_tag);
var r=Math.round((hasTags?7:5)*Math.sqrt(sc));
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+'"/>';
if(showLabels && i%labelEveryN===0){{
var lx=x, ly=y-r-5;
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>';
}}
}});
svg+='</svg>';
wrap.innerHTML=svg;
// Attach point tooltips
wrap.querySelectorAll('.trend-pt').forEach(function(c){{
c.addEventListener('mouseover',function(e){{
var d=pts[parseInt(this.dataset.idx)];
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(''):'';
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>':'';
showTT(e,
'<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(d.project_label)+'</strong>'+
(Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(Number(d[yKey]))+'</strong><br>'+
'Date: '+d.timestamp.substring(0,10)+(d.commit?'<br>Commit: <code>'+esc(d.commit.substring(0,12))+'</code>':'')+
(d.branch?'<br>Branch: '+esc(d.branch):'')+tagsHtml+nearestHtml
);
this.setAttribute('r','8');
}});
c.addEventListener('mouseout',function(){{hideTT();var _d=pts[parseInt(this.dataset.idx)];this.setAttribute('r',(_d.tags&&_d.tags.length)?'7':'5');}});
c.addEventListener('mousemove',moveTT);
c.addEventListener('click',function(){{
var d=pts[parseInt(this.dataset.idx)];
if(d.html_url) window.open(d.html_url,'_blank');
}});
}});
renderTable(pts, yKey);
}}
var shData=[], shSortCol=null, shSortOrder='asc', shPage=1, shPerPage=25;
var shProjFilter='', shBranchFilter='';
function fmtPST(isoStr){{
if(!isoStr)return'';
var d=new Date(isoStr);
if(isNaN(d.getTime()))return isoStr.substring(0,16).replace('T',' ');
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);}}
function p(n){{return n<10?'0'+n:String(n);}}
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++;}}}}
var yr=d.getUTCFullYear();
var dstStart=new Date(nthWeekdaySun(yr,2,2).getTime()+10*3600*1000);
var dstEnd=new Date(nthWeekdaySun(yr,10,1).getTime()+9*3600*1000);
var isDST=d>=dstStart&&d<dstEnd;
var off=isDST?-7*3600*1000:-8*3600*1000;
var lbl=isDST?'PDT':'PST';
var loc=new Date(d.getTime()+off);
return loc.getUTCFullYear()+'-'+p(loc.getUTCMonth()+1)+'-'+p(loc.getUTCDate())+' '+p(loc.getUTCHours())+':'+p(loc.getUTCMinutes())+' '+lbl;
}}
function getShRows(){{
var proj=shProjFilter.toLowerCase().trim();
var branch=shBranchFilter;
return shData.filter(function(d){{
if(proj&&!(d.project_label||'').toLowerCase().includes(proj))return false;
if(branch&&(d.branch||'')!==branch)return false;
return true;
}});
}}
function renderShPage(){{
var filtered=getShRows();
if(shSortCol){{
filtered.sort(function(a,b){{
var va,vb;
if(shSortCol==='metric'){{va=a._metricVal||0;vb=b._metricVal||0;return shSortOrder==='asc'?va-vb:vb-va;}}
if(shSortCol==='timestamp'){{va=a.timestamp||'';vb=b.timestamp||'';}}
else if(shSortCol==='project'){{va=(a.project_label||'').toLowerCase();vb=(b.project_label||'').toLowerCase();}}
else if(shSortCol==='branch'){{va=(a.branch||'').toLowerCase();vb=(b.branch||'').toLowerCase();}}
else{{va=String(a[shSortCol]||'').toLowerCase();vb=String(b[shSortCol]||'').toLowerCase();}}
return shSortOrder==='asc'?(va<vb?-1:va>vb?1:0):(va<vb?1:va>vb?-1:0);
}});
}}
var total=filtered.length,totalPages=Math.max(1,Math.ceil(total/shPerPage));
shPage=Math.min(shPage,totalPages);
var start=(shPage-1)*shPerPage,end=Math.min(start+shPerPage,total);
var visible=filtered.slice(start,end);
var tbody=document.getElementById('sh-tbody');
if(!tbody)return;
tbody.innerHTML=visible.map(function(d){{
var tsHtml=esc(fmtPST(d.timestamp));
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>';
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>';
var branchHtml=d.branch?'<span class="git-chip">'+esc(d.branch)+'</span>':'<span style="color:var(--muted)">—</span>';
var runIdHtml=d.run_id_short?'<span class="run-id-chip">'+esc(d.run_id_short)+'</span>':'—';
var metricHtml='<span class="metric-num">'+fmt(d._metricVal)+'</span>';
var reportCell='';
if(d.html_url){{
reportCell+='<div class="actions-cell"><a class="btn primary rpt-btn" href="'+esc(d.html_url)+'" target="_blank" rel="noopener">View</a>';
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>';}}
reportCell+='</div>';
}}else{{reportCell='<span style="color:var(--muted);font-size:11px;font-style:italic;">—</span>';}}
if(d.submodule_links&&d.submodule_links.length){{
reportCell+='<details class="submod-details"><summary>↳ '+d.submodule_links.length+' submodule(s)</summary><div class="submod-link-list">';
d.submodule_links.forEach(function(s){{reportCell+='<a href="'+esc(s.url)+'" target="_blank" rel="noopener" class="submod-view-btn">'+esc(s.name)+'</a>';}});
reportCell+='</div></details>';
}}
return '<tr>'
+'<td>'+tsHtml+'</td>'
+'<td title="'+esc(d.project_label)+'">'+esc(d.project_label)+'</td>'
+'<td>'+runIdHtml+'</td>'
+'<td>'+commitHtml+'</td>'
+'<td>'+branchHtml+'</td>'
+'<td>'+tags+'</td>'
+'<td class="num">'+metricHtml+'</td>'
+'<td class="report-cell">'+reportCell+'</td>'
+'</tr>';
}}).join('');
var pgRange=document.getElementById('sh-pg-range');
if(pgRange)pgRange.textContent=total?'Showing '+(start+1)+'\u2013'+end+' of '+total:'No results';
var pgInfo=document.getElementById('sh-pg-info');
if(pgInfo)pgInfo.textContent='Page '+shPage+' of '+totalPages;
var pgBtns=document.getElementById('sh-pg-btns');
if(pgBtns){{
pgBtns.innerHTML='';
function mkPgBtn(lbl,pg,active,disabled){{
var b=document.createElement('button');b.className='pg-btn'+(active?' active':'');b.textContent=lbl;b.disabled=disabled;
if(!disabled)b.addEventListener('click',function(){{shPage=pg;renderShPage();}});
return b;
}}
pgBtns.appendChild(mkPgBtn('\u2039',shPage-1,false,shPage===1));
var ws=Math.max(1,shPage-2),we=Math.min(totalPages,ws+4);ws=Math.max(1,we-4);
for(var pg=ws;pg<=we;pg++)pgBtns.appendChild(mkPgBtn(String(pg),pg,pg===shPage,false));
pgBtns.appendChild(mkPgBtn('\u203a',shPage+1,false,shPage===totalPages));
}}
}}
function wireTableBehavior(){{
var pf=document.getElementById('sh-proj-filter');
if(pf){{pf.value=shProjFilter;pf.addEventListener('input',function(){{shProjFilter=this.value;shPage=1;renderShPage();}});}}
var bf=document.getElementById('sh-branch-filter');
if(bf){{bf.value=shBranchFilter;bf.addEventListener('change',function(){{shBranchFilter=this.value;shPage=1;renderShPage();}});}}
var rb=document.getElementById('sh-reset-btn');
if(rb)rb.addEventListener('click',function(){{
shProjFilter='';shBranchFilter='';shSortCol=null;shSortOrder='asc';shPage=1;
var pf2=document.getElementById('sh-proj-filter');if(pf2)pf2.value='';
var bf2=document.getElementById('sh-branch-filter');if(bf2)bf2.value='';
document.querySelectorAll('#sh-thead .sortable').forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='\u2195';t.classList.remove('sort-asc','sort-desc');}});
renderShPage();
}});
var pps=document.getElementById('sh-per-page');
if(pps)pps.addEventListener('change',function(){{shPerPage=parseInt(this.value,10)||25;shPage=1;renderShPage();}});
var ths=Array.prototype.slice.call(document.querySelectorAll('#sh-thead .sortable'));
ths.forEach(function(th){{
th.addEventListener('click',function(e){{
if(e.target.classList.contains('col-resize-handle'))return;
var col=th.dataset.col;
if(shSortCol===col){{shSortOrder=shSortOrder==='asc'?'desc':'asc';}}else{{shSortCol=col;shSortOrder='asc';}}
ths.forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='\u2195';t.classList.remove('sort-asc','sort-desc');}});
th.classList.add('sort-'+shSortOrder);
var si=th.querySelector('.sort-icon');if(si)si.textContent=shSortOrder==='asc'?'\u2191':'\u2193';
shPage=1;renderShPage();
}});
}});
var table=document.getElementById('scan-history-table');
if(!table)return;
var cols=Array.prototype.slice.call(table.querySelectorAll('col'));
var allThs=Array.prototype.slice.call(table.querySelectorAll('#sh-thead th'));
allThs.forEach(function(th,i){{
var handle=th.querySelector('.col-resize-handle');
if(!handle||!cols[i])return;
var startX,startW;
handle.addEventListener('mousedown',function(e){{
e.stopPropagation();e.preventDefault();
startX=e.clientX;startW=cols[i].offsetWidth||th.offsetWidth;
handle.classList.add('dragging');
function onMove(ev){{cols[i].style.width=Math.max(40,startW+ev.clientX-startX)+'px';}}
function onUp(){{handle.classList.remove('dragging');document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);}}
document.addEventListener('mousemove',onMove);
document.addEventListener('mouseup',onUp);
}});
}});
}}
function renderTable(pts, yKey){{
var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comments',blank_lines:'Blanks',physical_lines:'Physical',files_analyzed:'Files'}};
var wrap=document.getElementById('data-table-wrap');
if(!pts||!pts.length){{wrap.innerHTML='';return;}}
var yLabel=Y_LABELS[yKey]||yKey||'';
shData=pts.slice().reverse();
shSortCol=null;shSortOrder='asc';shPage=1;shProjFilter='';shBranchFilter='';
shData.forEach(function(d){{d._metricVal=Number(d[yKey])||0;}});
var branches={{}};
shData.forEach(function(d){{if(d.branch)branches[d.branch]=true;}});
var branchOpts='<option value="">All branches</option>';
Object.keys(branches).sort().forEach(function(b){{branchOpts+='<option value="'+esc(b)+'">'+esc(b)+'</option>';}});
wrap.innerHTML=
'<div class="chart-section-header">SCAN HISTORY</div>'+
'<div class="filter-row">'+
'<input class="filter-input" id="sh-proj-filter" type="text" placeholder="Filter by path or name\u2026">'+
'<select class="filter-select" id="sh-branch-filter">'+branchOpts+'</select>'+
'<button type="button" class="btn" id="sh-reset-btn">\u21bb Reset view</button>'+
'</div>'+
'<div class="table-wrap">'+
'<table id="scan-history-table" class="data-table">'+
'<colgroup><col><col><col><col><col><col><col><col></colgroup>'+
'<thead><tr id="sh-thead">'+
'<th class="sortable" data-col="timestamp" data-type="str">Scan Date<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
'<th class="sortable" data-col="project" data-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
'<th>Run ID<div class="col-resize-handle"></div></th>'+
'<th>Commit<div class="col-resize-handle"></div></th>'+
'<th class="sortable" data-col="branch" data-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
'<th>Tags<div class="col-resize-handle"></div></th>'+
'<th class="sortable num" data-col="metric" data-type="num">'+esc(yLabel)+'<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
'<th>Report<div class="col-resize-handle"></div></th>'+
'</tr></thead>'+
'<tbody id="sh-tbody"></tbody>'+
'</table>'+
'</div>'+
'<div class="pagination">'+
'<span class="pagination-info" id="sh-pg-info"></span>'+
'<div class="pagination-btns" id="sh-pg-btns"></div>'+
'<div style="display:flex;align-items:center;gap:8px;">'+
'<span style="font-size:13px;color:var(--muted);">Show</span>'+
'<select class="filter-select" id="sh-per-page">'+
'<option value="10">10 per page</option>'+
'<option value="25" selected>25 per page</option>'+
'<option value="50">50 per page</option>'+
'<option value="100">100 per page</option>'+
'</select>'+
'<span style="font-size:13px;color:var(--muted);" id="sh-pg-range"></span>'+
'</div>'+
'</div>';
wireTableBehavior();
renderShPage();
}}
function exportXLSX(){{
if(!allData||!allData.length){{alert('No data to export yet.');return;}}
var sorted=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
var s1H=['Date','Project','Commit','Branch','Tags','Code Lines','Comment Lines','Blank Lines','Physical Lines','Files Analyzed','Report URL'];
var s1R=sorted.map(function(d){{
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||''];
}});
var pm={{}};
sorted.forEach(function(d){{var p=d.project_label||'Unknown';if(!pm[p])pm[p]=[];pm[p].push(d);}});
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'];
var s2R=Object.keys(pm).map(function(p){{
var sc=pm[p].slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
var lat=sc[sc.length-1],fst=sc[0];
var codes=sc.map(function(s){{return+(s.code_lines)||0;}});
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);
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];
}});
var buf=buildXLSX([{{name:'Scan History',headers:s1H,rows:s1R}},{{name:'By Project',headers:s2H,rows:s2R}}],s1R,s2R);
var a=document.createElement('a');a.download='oxide-sloc-trend.xlsx';
a.href=URL.createObjectURL(new Blob([buf],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
}}
function buildXLSX(sheets,chartRows,chartRows2){{
function s2b(s){{return new TextEncoder().encode(s);}}
function xe(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}}
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;}}
function crc32(d){{
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;}}}}
var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
}}
function buildSheet(hdr,rows,drawRid,withCtrl){{
var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'><sheetData>';
x+='<row r="1">';
hdr.forEach(function(h,ci){{x+='<c r="'+col2l(ci+1)+'1" t="inlineStr" s="1"><is><t>'+xe(h)+'</t></is></c>';}});
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>';}}
x+='</row>';
rows.forEach(function(row,ri){{
var rn=ri+2;
x+='<row r="'+rn+'">';
row.forEach(function(cell,ci){{
var addr=col2l(ci+1)+rn;
if(typeof cell==='number'){{x+='<c r="'+addr+'"><v>'+cell+'</v></c>';}}
else{{x+='<c r="'+addr+'" t="inlineStr"><is><t>'+xe(String(cell))+'</t></is></c>';}}
}});
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>';}}
x+='</row>';
}});
x+='</sheetData>';
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>';}}
if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
return x+'</worksheet>';
}}
function buildChartXML(rows){{
var sn="'Scan History'";
var nr=rows.length,er=nr+1;
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'}}];
var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
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">';
x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="1"/><c:plotArea>';
x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
sd.forEach(function(s,i){{
x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
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>';
x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
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>';
var dlp=(i===2)?'b':'t';
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>';
x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
x+='</c:strCache></c:strRef></c:cat>';
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+'"/>';
rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
}});
x+='<c:axId val="1"/><c:axId val="2"/></c:lineChart>';
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>';
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>';
x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
return x;
}}
function buildChartXML2(rows){{
var sn="'By Project'";
var nr=rows.length,er=nr+1;
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'}}];
var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
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">';
x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="1"/><c:plotArea>';
x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
sd.forEach(function(s,i){{
x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
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>';
x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
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>';
var dlp=(i===2)?'b':'t';
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>';
x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
x+='</c:strCache></c:strRef></c:cat>';
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+'"/>';
rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
}});
x+='<c:axId val="3"/><c:axId val="4"/></c:lineChart>';
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>';
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>';
x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
return x;
}}
function buildChartXML3(rows){{
var sn="'Scan History'";
var nr=rows.length,er=nr+1;
var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
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">';
x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="0"/><c:plotArea>';
x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
x+='<c:ser><c:idx val="0"/><c:order val="0"/>';
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>';
x+='<c:spPr><a:ln w="31750"><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill></a:ln></c:spPr>';
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>';
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>';
x+='<c:cat><c:strRef><c:f>'+sn+'!$A$2:$A$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[0]))+'</c:v></c:pt>';}});
x+='</c:strCache></c:strRef></c:cat>';
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+'"/>';
rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[5])+'</c:v></c:pt>';}});
x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
x+='<c:axId val="5"/><c:axId val="6"/></c:lineChart>';
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>';
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>';
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>';
return x;
}}
var hasChart=!!(chartRows&&chartRows.length);
var nr=hasChart?chartRows.length:0;
var hasChart2=!!(chartRows2&&chartRows2.length);
var nr2=hasChart2?chartRows2.length:0;
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>';
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"/>';
sheets.forEach(function(s,i){{ct+='<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}});
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"/>';}}
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"/>';}}
ct+='<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
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>';
var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">';
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"/>';}});
wbr+='<Relationship Id="rId'+(sheets.length+1)+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
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>';
sheets.forEach(function(s,i){{wbx+='<sheet name="'+xe(s.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}});
wbx+='</sheets></workbook>';
var files=[
{{name:'[Content_Types].xml',data:s2b(ct)}},
{{name:'_rels/.rels',data:s2b(dotrels)}},
{{name:'xl/workbook.xml',data:s2b(wbx)}},
{{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
{{name:'xl/styles.xml',data:s2b(styl)}}
];
// Chart embedded directly in Scan History (sheet1); By Project is plain
sheets.forEach(function(s,i){{
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)))}});
}});
if(hasChart){{
var fromRow=nr+4,toRow=nr+24;
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>')}});
var drx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
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">';
drx+='<xdr:twoCellAnchor editAs="twoCell">';
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>';
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>';
drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="2" name="Chart 1"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor>';
var focRow=toRow+2,focRowEnd=toRow+22;
drx+='<xdr:twoCellAnchor editAs="twoCell">';
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>';
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>';
drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="4" name="Chart 3"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId2"/>';
drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
files.push({{name:'xl/drawings/drawing1.xml',data:s2b(drx)}});
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>')}});
files.push({{name:'xl/charts/chart1.xml',data:s2b(buildChartXML(chartRows))}});
files.push({{name:'xl/charts/chart3.xml',data:s2b(buildChartXML3(chartRows))}});
}}
if(hasChart2){{
var fromRow2=nr2+4,toRow2=nr2+24;
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>')}});
var drx2='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
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">';
drx2+='<xdr:twoCellAnchor editAs="twoCell">';
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>';
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>';
drx2+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="3" name="Chart 2"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
drx2+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
drx2+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
drx2+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
drx2+='<\/a:graphicData><\/a:graphic><\/xdr:graphicFrame><xdr:clientData\/><\/xdr:twoCellAnchor><\/xdr:wsDr>';
files.push({{name:'xl/drawings/drawing2.xml',data:s2b(drx2)}});
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>')}});
files.push({{name:'xl/charts/chart2.xml',data:s2b(buildChartXML2(chartRows2))}});
}}
var parts=[],offsets=[],total=0;
files.forEach(function(f){{
offsets.push(total);
var nb=s2b(f.name),crc=crc32(f.data);
var h=new DataView(new ArrayBuffer(30+nb.length));
h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);
h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);
h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);
h.setUint16(26,nb.length,true);h.setUint16(28,0,true);
for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);
parts.push(new Uint8Array(h.buffer));parts.push(f.data);
total+=30+nb.length+f.data.length;
}});
var cdStart=total;
files.forEach(function(f,fi){{
var nb=s2b(f.name),crc=crc32(f.data);
var cd=new DataView(new ArrayBuffer(46+nb.length));
cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);
cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);
cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);
cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);
cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);
for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);
parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;
}});
var cdSz=total-cdStart;
var eocd=new DataView(new ArrayBuffer(22));
eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);
eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);
eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);
parts.push(new Uint8Array(eocd.buffer));
var sz=parts.reduce(function(a,p){{return a+p.length;}},0);
var out=new Uint8Array(sz);var off=0;
parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
return out.buffer;
}}
function exportPNG(){{
var svgEl=document.querySelector('#chart-wrap svg');
if(!svgEl){{alert('No chart to export yet.');return;}}
var svgStr=new XMLSerializer().serializeToString(svgEl);
var vb=svgEl.viewBox.baseVal,scale=2;
var w=(vb.width||900)*scale,h=(vb.height||380)*scale;
var blob=new Blob([svgStr],{{type:'image/svg+xml'}});
var url=URL.createObjectURL(blob);
var img=new Image();
img.onload=function(){{
var canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
var ctx=canvas.getContext('2d');
var bg=getComputedStyle(document.body).getPropertyValue('--bg').trim()||'#f5efe8';
ctx.fillStyle=bg;ctx.fillRect(0,0,w,h);
ctx.scale(scale,scale);ctx.drawImage(img,0,0);
URL.revokeObjectURL(url);
var a=document.createElement('a');a.download='oxide-sloc-trend.png';a.href=canvas.toDataURL('image/png');a.click();
}};
img.src=url;
}}
['y-sel','x-sel','scale-sel'].forEach(function(id){{
var el=document.getElementById(id);
if(el)el.addEventListener('change',function(){{render(allData);updateStats(allData);}});
}});
rootSel.addEventListener('change',function(){{
populateSubmodules(rootSel.value);
loadAndRender();
}});
if(subSel)subSel.addEventListener('change',loadAndRender);
var xlsxBtn=document.getElementById('export-xlsx-btn');
if(xlsxBtn)xlsxBtn.addEventListener('click',exportXLSX);
var pngBtn=document.getElementById('export-png-btn');
if(pngBtn)pngBtn.addEventListener('click',exportPNG);
// ── Clean-up modal ───────────────────────────────────────────────────────
(function(){{
var triggerBtn=document.getElementById('cleanup-runs-btn');
if(!triggerBtn)return;
var modal=document.createElement('div');
modal.style.cssText='display:none;position:fixed;inset:0;z-index:9000;background:rgba(0,0,0,0.55);align-items:center;justify-content:center;';
modal.innerHTML='<div style="background:var(--surface);border:1px solid var(--line);border-radius:14px;padding:28px 32px;max-width:460px;width:95%;box-shadow:0 16px 48px rgba(0,0,0,0.28);">'
+'<div style="font-size:16px;font-weight:800;margin-bottom:10px;">Clean up old runs</div>'
+'<p style="font-size:13px;color:var(--text);margin:0 0 14px;">Delete all scan artifacts older than the chosen number of days. This removes files from disk and clears the registry. <strong>This cannot be undone.</strong></p>'
+'<label style="font-size:12px;font-weight:700;color:var(--muted);">Delete runs older than</label>'
+'<div style="display:flex;align-items:center;gap:8px;margin:6px 0 16px;">'
+'<input type="number" id="cleanup-days-input" value="30" min="1" max="3650" style="width:80px;padding:7px 10px;border-radius:8px;border:1.5px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:14px;font-weight:700;">'
+'<span style="font-size:13px;color:var(--muted);">days</span></div>'
+'<div id="cleanup-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>'
+'<div style="display:flex;gap:10px;justify-content:flex-end;">'
+'<button class="button secondary" id="cleanup-cancel-btn" type="button">Cancel</button>'
+'<button class="button" id="cleanup-confirm-btn" type="button" style="background:#b23030;border-color:#b23030;">Delete old runs</button>'
+'</div></div>';
document.body.appendChild(modal);
triggerBtn.addEventListener('click',function(){{
document.getElementById('cleanup-status').style.display='none';
modal.style.display='flex';
}});
document.getElementById('cleanup-cancel-btn').addEventListener('click',function(){{modal.style.display='none';}});
modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
document.getElementById('cleanup-confirm-btn').addEventListener('click',function(){{
var days=parseInt(document.getElementById('cleanup-days-input').value,10)||30;
var confirmBtn=this;
confirmBtn.disabled=true;
var status=document.getElementById('cleanup-status');
status.style.display='block';
status.style.background='#dbeafe';status.style.color='#1e40af';
status.textContent='Deleting\u2026';
fetch('/api/runs/cleanup',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{older_than_days:days}})}})
.then(function(resp){{
return resp.json().then(function(d){{
if(resp.ok){{
status.style.background='#dcfce7';status.style.color='#166534';
status.textContent='Deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+' older than '+days+' days. Refreshing\u2026';
setTimeout(function(){{window.location.reload();}},1500);
}}else{{
status.style.background='#fee2e2';status.style.color='#991b1b';
status.textContent='Error: '+(d.error||'Unexpected error');
confirmBtn.disabled=false;
}}
}});
}})
.catch(function(e){{
status.style.background='#fee2e2';status.style.color='#991b1b';
status.textContent='Network error: '+String(e);
confirmBtn.disabled=false;
}});
}});
}})();
// ── Retention policy panel ────────────────────────────────────────────────
(function(){{
var triggerBtn=document.getElementById('retention-policy-btn');
if(!triggerBtn)return;
var modal=document.createElement('div');
modal.style.cssText='display:none;position:fixed;inset:0;z-index:9001;background:rgba(0,0,0,0.72);align-items:center;justify-content:center;';
modal.innerHTML=''
+'<div style="background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:36px 44px;max-width:580px;width:95%;box-shadow:0 24px 64px rgba(0,0,0,0.38);">'
+'<div style="font-size:19px;font-weight:800;margin-bottom:6px;">Retention Policy</div>'
+'<p style="font-size:13px;color:var(--muted);margin:0 0 22px;">Automatically clean up old scan runs on a schedule. Both rules apply when set — a run is deleted if it exceeds the age limit <em>or</em> falls outside the count limit.</p>'
+'<div style="display:flex;align-items:center;gap:10px;margin-bottom:22px;">'
+'<input type="checkbox" id="rp-enabled" style="width:16px;height:16px;cursor:pointer;accent-color:var(--oxide);">'
+'<label for="rp-enabled" style="font-size:14px;font-weight:700;cursor:pointer;">Enable auto-cleanup</label>'
+'</div>'
+'<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-bottom:20px;">'
+'<div>'
+'<label style="font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;display:block;margin-bottom:6px;">Max age (days)</label>'
+'<input type="number" id="rp-max-age" min="1" max="3650" placeholder="No limit" style="width:100%;padding:9px 12px;border-radius:8px;border:1.5px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:14px;box-sizing:border-box;">'
+'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Delete runs older than N days</div>'
+'</div>'
+'<div>'
+'<label style="font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;display:block;margin-bottom:6px;">Max runs kept</label>'
+'<input type="number" id="rp-max-count" min="1" max="10000" placeholder="No limit" style="width:100%;padding:9px 12px;border-radius:8px;border:1.5px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:14px;box-sizing:border-box;">'
+'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Keep only the N most recent runs</div>'
+'</div>'
+'</div>'
+'<div style="margin-bottom:20px;">'
+'<label style="font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;display:block;margin-bottom:6px;">Check interval</label>'
+'<select id="rp-interval" style="padding:9px 12px;border-radius:8px;border:1.5px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:14px;min-width:180px;">'
+'<option value="1">Every hour</option>'
+'<option value="6">Every 6 hours</option>'
+'<option value="12">Every 12 hours</option>'
+'<option value="24" selected>Every 24 hours</option>'
+'<option value="48">Every 2 days</option>'
+'<option value="72">Every 3 days</option>'
+'<option value="168">Every week</option>'
+'</select>'
+'</div>'
+'<div id="rp-last-run" style="padding:10px 14px;border-radius:8px;background:var(--surface-2);font-size:12px;color:var(--muted);margin-bottom:20px;">—</div>'
+'<div id="rp-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:18px;"></div>'
+'<div style="display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;">'
+'<button class="button secondary" id="rp-close-btn" type="button">Close</button>'
+'<button class="button secondary" id="rp-run-now-btn" type="button">Run Now</button>'
+'<button class="button" id="rp-save-btn" type="button">Save Policy</button>'
+'</div>'
+'</div>';
document.body.appendChild(modal);
function rpShowStatus(msg,ok){{
var s=document.getElementById('rp-status');
s.style.display='block';
s.style.background=ok?'#dcfce7':'#fee2e2';
s.style.color=ok?'#166534':'#991b1b';
s.textContent=msg;
}}
function fmtAgo(iso){{
if(!iso)return'Never';
var diff=Math.floor((Date.now()-new Date(iso).getTime())/1000);
if(diff<60)return diff+'s ago';
if(diff<3600)return Math.floor(diff/60)+'m ago';
if(diff<86400)return Math.floor(diff/3600)+'h ago';
return Math.floor(diff/86400)+'d ago';
}}
function loadPolicy(){{
fetch('/api/cleanup-policy')
.then(function(r){{return r.json();}})
.then(function(d){{
var p=d.policy;
document.getElementById('rp-enabled').checked=p?p.enabled:false;
document.getElementById('rp-max-age').value=(p&&p.max_age_days!=null)?p.max_age_days:'';
document.getElementById('rp-max-count').value=(p&&p.max_run_count!=null)?p.max_run_count:'';
var sel=document.getElementById('rp-interval');
if(p){{var iv=String(p.interval_hours||24);for(var i=0;i<sel.options.length;i++){{if(sel.options[i].value===iv){{sel.selectedIndex=i;break;}}}}}}
var lr=document.getElementById('rp-last-run');
if(d.last_run_at){{
lr.textContent='Last run: '+fmtAgo(d.last_run_at)+(d.last_run_deleted!=null?' \u00b7 deleted '+d.last_run_deleted+' run'+(d.last_run_deleted===1?'':'s'):'');
}}else{{
lr.textContent='Auto-cleanup has not run yet.';
}}
}})
.catch(function(){{document.getElementById('rp-last-run').textContent='Could not load policy.';}});
}}
triggerBtn.addEventListener('click',function(){{
document.getElementById('rp-status').style.display='none';
loadPolicy();
modal.style.display='flex';
}});
document.getElementById('rp-close-btn').addEventListener('click',function(){{modal.style.display='none';}});
modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
document.getElementById('rp-save-btn').addEventListener('click',function(){{
var enabled=document.getElementById('rp-enabled').checked;
var ageVal=document.getElementById('rp-max-age').value.trim();
var countVal=document.getElementById('rp-max-count').value.trim();
var intervalHours=parseInt(document.getElementById('rp-interval').value,10)||24;
if(enabled&&!ageVal&&!countVal){{
rpShowStatus('Set at least one rule (max age or max count) before enabling.',false);
return;
}}
var body={{enabled:enabled,max_age_days:ageVal?parseInt(ageVal,10):null,max_run_count:countVal?parseInt(countVal,10):null,interval_hours:intervalHours}};
var saveBtn=document.getElementById('rp-save-btn');
saveBtn.disabled=true;
fetch('/api/cleanup-policy',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify(body)}})
.then(function(r){{
if(r.status===204||r.ok){{rpShowStatus('Policy saved'+(enabled?'. Background task started.':'.'),true);}}
else{{return r.json().then(function(d){{rpShowStatus('Error: '+(d.error||'Unexpected error'),false);}});}}
}})
.catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
.finally(function(){{saveBtn.disabled=false;}});
}});
document.getElementById('rp-run-now-btn').addEventListener('click',function(){{
var btn=this;
btn.disabled=true;
btn.textContent='Running\u2026';
fetch('/api/cleanup-policy/run-now',{{method:'POST'}})
.then(function(r){{return r.json();}})
.then(function(d){{
rpShowStatus('Cleanup complete: deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+'.',true);
loadPolicy();
}})
.catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
.finally(function(){{btn.disabled=false;btn.textContent='Run Now';}});
}});
}})();
populateSubmodules(rootSel.value);
loadAndRender();
(function randomizeWatermarks() {{
var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
if (!wms.length) return;
var placed = [];
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;
}}
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];
}}
var half = Math.floor(wms.length / 2);
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;
}});
}})();
(function spawnCodeParticles() {{
var container = document.getElementById('code-particles');
if (!container) return;
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'
];
var count = 38;
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.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
container.appendChild(el);
}})(i);
}}
}})();
</script>
<footer class="site-footer">
local code analysis - metrics, history and reports
· <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{version} — Mode: Local</em>
· Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
· <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
· <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
· <a href="/api-docs" rel="noopener">REST API</a>
</footer>
<script nonce="{nonce}">(function(){{var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';if(lbl)lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{version} — Mode: '+(isServer?'Network Server':'Local');function setDot(ms){{if(!dot)return;if(ms<100){{dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}}else if(ms<300){{dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}}else{{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}}function doPing(){{var t0=performance.now();fetch('/healthz',{{cache:'no-store'}}).then(function(){{var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}}).catch(function(){{if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}});}}doPing();setInterval(doPing,5000);}})();</script>
</body>
</html>"##,
);
Html(html).into_response()
}
fn compute_cov_pct_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
use std::collections::HashMap;
if !per_file_records.iter().any(|f| f.coverage.is_some()) {
return vec![];
}
let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
for rec in per_file_records {
if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
let e = totals.entry(lang.display_name().to_string()).or_default();
e.0 += u64::from(cov.lines_found);
e.1 += u64::from(cov.lines_hit);
}
}
#[allow(clippy::cast_precision_loss)] // hit/found are line counts bounded by file size
let mut pairs: Vec<(String, f64)> = totals
.into_iter()
.filter(|(_, (found, _))| *found > 0)
.map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
.collect();
pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
pairs
.iter()
.map(|(lang, pct)| serde_json::json!({"lang": lang, "pct": (pct * 10.0).round() / 10.0}))
.collect()
}
fn compute_cov_tiers(per_file_records: &[sloc_core::FileRecord]) -> (u64, u64, u64) {
let mut high = 0u64;
let mut mid = 0u64;
let mut low = 0u64;
for rec in per_file_records {
if let Some(cov) = &rec.coverage {
if cov.lines_found == 0 {
continue;
}
let pct = f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0;
if pct >= 80.0 {
high += 1;
} else if pct >= 50.0 {
mid += 1;
} else {
low += 1;
}
}
}
(high, mid, low)
}
fn compute_file_cov_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
let mut arr: Vec<serde_json::Value> = per_file_records
.iter()
.filter_map(|rec| {
rec.coverage.as_ref().map(|cov| {
let line_pct = if cov.lines_found > 0 {
(f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0 * 10.0).round()
/ 10.0
} else {
0.0
};
let fn_pct = if cov.functions_found > 0 {
(f64::from(cov.functions_hit) / f64::from(cov.functions_found) * 100.0 * 10.0)
.round()
/ 10.0
} else {
-1.0
};
serde_json::json!({
"rel": rec.relative_path,
"lang": rec.language.map_or("?", |l| l.display_name()),
"line_pct": line_pct,
"fn_pct": fn_pct,
"lhit": cov.lines_hit,
"lfound": cov.lines_found,
"fhit": cov.functions_hit,
"ffound": cov.functions_found,
})
})
})
.collect();
arr.sort_by(|a, b| {
let pa = a["line_pct"].as_f64().unwrap_or(0.0);
let pb = b["line_pct"].as_f64().unwrap_or(0.0);
pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
});
arr
}
#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
fn build_test_scope_entry(run: &AnalysisRun) -> serde_json::Value {
let mut langs: Vec<&sloc_core::LanguageSummary> = run
.totals_by_language
.iter()
.filter(|l| l.test_count > 0)
.collect();
langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
let lang_tests: Vec<serde_json::Value> = langs
.iter()
.map(|l| {
let d = if l.code_lines > 0 {
l.test_count as f64 / l.code_lines as f64 * 1000.0
} else {
0.0
};
serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
"assertions": l.test_assertion_count, "suites": l.test_suite_count,
"code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
})
.collect();
let cov_arr = compute_cov_pct_arr(&run.per_file_records);
let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
let t = &run.summary_totals;
let total_tests = t.test_count;
let density = if t.code_lines > 0 {
total_tests as f64 / t.code_lines as f64 * 1000.0
} else {
0.0
};
let most_tested = langs.first().map_or_else(
|| "\u{2014}".to_string(),
|l| l.language.display_name().to_string(),
);
let test_files: u64 = run
.per_file_records
.iter()
.filter(|f| f.raw_line_categories.test_count > 0)
.count() as u64;
let cov_line = if t.coverage_lines_found > 0 {
format!(
"{:.1}",
t.coverage_lines_hit as f64 / t.coverage_lines_found as f64 * 100.0
)
} else {
"0".to_string()
};
let cov_fn = if t.coverage_functions_found > 0 {
format!(
"{:.1}",
t.coverage_functions_hit as f64 / t.coverage_functions_found as f64 * 100.0
)
} else {
"0".to_string()
};
let cov_branch = if t.coverage_branches_found > 0 {
format!(
"{:.1}",
t.coverage_branches_hit as f64 / t.coverage_branches_found as f64 * 100.0
)
} else {
"0".to_string()
};
let has_cov = !cov_arr.is_empty();
let file_cov_arr = compute_file_cov_arr(&run.per_file_records);
serde_json::json!({
"totals": {
"test_count": total_tests,
"assertions": t.test_assertion_count,
"suites": t.test_suite_count,
"test_files": test_files,
"total_files": t.files_analyzed,
"density_str": format!("{density:.1}"),
"most_tested": most_tested,
"langs_with_tests": langs.len(),
"cov_line": cov_line,
"cov_fn": cov_fn,
"cov_branch": cov_branch,
},
"lang_tests": lang_tests,
"cov": cov_arr,
"cov_tiers": {"high": high, "mid": mid, "low": low},
"file_cov": file_cov_arr,
"has_coverage": has_cov,
"submodules": {},
})
}
#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
fn build_test_scope_sub_entry(sub: &sloc_core::SubmoduleSummary) -> serde_json::Value {
let mut langs: Vec<&sloc_core::LanguageSummary> = sub
.language_summaries
.iter()
.filter(|l| l.test_count > 0)
.collect();
langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
let lang_tests: Vec<serde_json::Value> = langs
.iter()
.map(|l| {
let d = if l.code_lines > 0 {
l.test_count as f64 / l.code_lines as f64 * 1000.0
} else {
0.0
};
serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
"assertions": l.test_assertion_count, "suites": l.test_suite_count,
"code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
})
.collect();
let total_tests: u64 = langs.iter().map(|l| l.test_count).sum();
let total_assertions: u64 = langs.iter().map(|l| l.test_assertion_count).sum();
let total_suites: u64 = langs.iter().map(|l| l.test_suite_count).sum();
let test_files_approx: u64 = langs.iter().map(|l| l.files).sum();
let density = if sub.code_lines > 0 {
total_tests as f64 / sub.code_lines as f64 * 1000.0
} else {
0.0
};
let most_tested = langs.first().map_or_else(
|| "\u{2014}".to_string(),
|l| l.language.display_name().to_string(),
);
serde_json::json!({
"totals": {
"test_count": total_tests,
"assertions": total_assertions,
"suites": total_suites,
"test_files": test_files_approx,
"total_files": sub.files_analyzed,
"density_str": format!("{density:.1}"),
"most_tested": most_tested,
"langs_with_tests": langs.len(),
"cov_line": "0",
"cov_fn": "0",
"cov_branch": "0",
},
"lang_tests": lang_tests,
"cov": [],
"cov_tiers": {"high": 0, "mid": 0, "low": 0},
"has_coverage": false,
})
}
fn compute_cov_json_str(run: &AnalysisRun) -> String {
use std::collections::HashMap;
let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
for rec in &run.per_file_records {
if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
let e = totals.entry(lang.display_name().to_string()).or_default();
e.0 += u64::from(cov.lines_found);
e.1 += u64::from(cov.lines_hit);
}
}
#[allow(clippy::cast_precision_loss)] // hit/found are line counts bounded by file size
let mut pairs: Vec<(String, f64)> = totals
.into_iter()
.filter(|(_, (found, _))| *found > 0)
.map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
.collect();
pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let parts: Vec<String> = pairs
.iter()
.map(|(lang, pct)| {
let name = lang.replace('"', "\\\"");
format!(r#"{{"lang":"{name}","pct":{pct:.1}}}"#)
})
.collect();
format!("[{}]", parts.join(","))
}
fn compute_cov_tier_json_str(run: &AnalysisRun) -> String {
let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
format!(r#"{{"high":{high},"mid":{mid},"low":{low}}}"#)
}
fn build_scope_entry_for_run(run: &AnalysisRun) -> serde_json::Value {
let mut entry = build_test_scope_entry(run);
if !run.submodule_summaries.is_empty() {
let subs: serde_json::Map<String, serde_json::Value> = run
.submodule_summaries
.iter()
.map(|sub| (sub.name.clone(), build_test_scope_sub_entry(sub)))
.collect();
entry["submodules"] = serde_json::Value::Object(subs);
}
entry
}
fn lang_test_entry_json(l: &sloc_core::LanguageSummary) -> String {
let name = l.language.display_name().replace('"', "\\\"");
#[allow(clippy::cast_precision_loss)] // ratio for density display; precision loss acceptable
let density = if l.code_lines > 0 {
l.test_count as f64 / l.code_lines as f64 * 1000.0
} else {
0.0
};
format!(
r#"{{"lang":"{name}","tests":{t},"assertions":{a},"suites":{s},"code":{c},"density":{d:.2},"files":{f}}}"#,
name = name,
t = l.test_count,
a = l.test_assertion_count,
s = l.test_suite_count,
c = l.code_lines,
d = density,
f = l.files,
)
}
fn build_lang_tests_json(run: Option<&AnalysisRun>) -> String {
let Some(r) = run else {
return "[]".to_string();
};
let mut langs: Vec<&sloc_core::LanguageSummary> = r
.totals_by_language
.iter()
.filter(|l| l.test_count > 0)
.collect();
langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
let parts: Vec<String> = langs.iter().map(|l| lang_test_entry_json(l)).collect();
format!("[{}]", parts.join(","))
}
/// Build the per-root scope JSON used by the test-metrics page JS scope switcher.
async fn build_scope_data_json(state: &AppState, latest_run: Option<&AnalysisRun>) -> String {
let mut scope_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
scope_map.insert(
"__all__".to_string(),
latest_run.map_or_else(
|| {
serde_json::json!({"totals":{"test_count":0,"assertions":0,"suites":0,
"test_files":0,"total_files":0,"density_str":"0.0","most_tested":"\u{2014}",
"langs_with_tests":0,"cov_line":"0","cov_fn":"0","cov_branch":"0"},
"lang_tests":[],"cov":[],"cov_tiers":{"high":0,"mid":0,"low":0},
"has_coverage":false,"submodules":{}})
},
build_test_scope_entry,
),
);
let all_roots: Vec<String> = {
let reg = state.registry.lock().await;
let mut seen = std::collections::BTreeSet::new();
reg.entries
.iter()
.flat_map(|e| e.input_roots.iter().cloned())
.filter(|r| seen.insert(r.clone()))
.collect()
};
for root in &all_roots {
let json_path = {
let reg = state.registry.lock().await;
reg.entries
.iter()
.find(|e| e.input_roots.iter().any(|r| r == root))
.and_then(|e| e.json_path.clone())
};
let run_for_root: Option<AnalysisRun> = if let Some(p) = json_path {
let json_str = tokio::fs::read_to_string(&p).await.ok();
json_str
.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
} else {
None
};
if let Some(ref run) = run_for_root {
scope_map.insert(root.clone(), build_scope_entry_for_run(run));
}
}
serde_json::to_string(&scope_map).unwrap_or_else(|_| "{}".to_string())
}
// GET /test-metrics
#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
#[allow(clippy::too_many_lines)] // test-metrics page with inline HTML; splitting would fragment the template
async fn test_metrics_handler(
State(state): State<AppState>,
axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
) -> Response {
auto_scan_watched_dirs(&state).await;
let watched_dirs_list: Vec<String> = {
let wd = state.watched_dirs.lock().await;
wd.dirs.iter().map(|p| p.display().to_string()).collect()
};
let latest_run: Option<AnalysisRun> = {
let json_path = {
let reg = state.registry.lock().await;
reg.entries.first().and_then(|e| e.json_path.clone())
};
if let Some(p) = json_path {
let json_str = tokio::fs::read_to_string(&p).await.ok();
json_str
.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
} else {
None
}
};
// Build per-language chart JSON (kept for has_coverage derivation via cov_json).
let _lang_tests_json = build_lang_tests_json(latest_run.as_ref());
// Build coverage chart JSON (per-language avg line coverage %).
let cov_json: String = latest_run
.as_ref()
.filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
.map_or_else(|| "[]".to_string(), compute_cov_json_str);
// Coverage tier distribution (pre-computed into SCOPE_DATA; unused as format arg).
let _cov_tier_json: String = latest_run
.as_ref()
.filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
.map_or_else(
|| r#"{"high":0,"mid":0,"low":0}"#.to_string(),
compute_cov_tier_json_str,
);
let total_tests: u64 = latest_run
.as_ref()
.map_or(0, |r| r.summary_totals.test_count);
let total_assertions: u64 = latest_run
.as_ref()
.map_or(0, |r| r.summary_totals.test_assertion_count);
let total_suites: u64 = latest_run
.as_ref()
.map_or(0, |r| r.summary_totals.test_suite_count);
let total_code: u64 = latest_run
.as_ref()
.map_or(0, |r| r.summary_totals.code_lines);
let workspace_density: f64 = if total_code > 0 {
total_tests as f64 / total_code as f64 * 1000.0
} else {
0.0
};
let langs_with_tests: usize = latest_run.as_ref().map_or(0, |r| {
r.totals_by_language
.iter()
.filter(|l| l.test_count > 0)
.count()
});
let most_tested: String = latest_run
.as_ref()
.and_then(|r| {
r.totals_by_language
.iter()
.filter(|l| l.test_count > 0)
.max_by_key(|l| l.test_count)
})
.map_or_else(
|| "\u{2014}".to_string(),
|l| l.language.display_name().to_string(),
);
let test_files_count: u64 = latest_run.as_ref().map_or(0, |r| {
r.per_file_records
.iter()
.filter(|f| f.raw_line_categories.test_count > 0)
.count() as u64
});
let total_files_analyzed: u64 = latest_run
.as_ref()
.map_or(0, |r| r.summary_totals.files_analyzed);
let has_coverage = !cov_json.starts_with("[]") && cov_json.len() > 2;
// Aggregated coverage percentages from summary_totals
let cov_line_pct_str: String = latest_run
.as_ref()
.filter(|r| r.summary_totals.coverage_lines_found > 0)
.map_or_else(
|| "0".to_string(),
|r| {
format!(
"{:.1}",
r.summary_totals.coverage_lines_hit as f64
/ r.summary_totals.coverage_lines_found as f64
* 100.0
)
},
);
let cov_fn_pct_str: String = latest_run
.as_ref()
.filter(|r| r.summary_totals.coverage_functions_found > 0)
.map_or_else(
|| "0".to_string(),
|r| {
format!(
"{:.1}",
r.summary_totals.coverage_functions_hit as f64
/ r.summary_totals.coverage_functions_found as f64
* 100.0
)
},
);
let cov_branch_pct_str: String = latest_run
.as_ref()
.filter(|r| r.summary_totals.coverage_branches_found > 0)
.map_or_else(
|| "0".to_string(),
|r| {
format!(
"{:.1}",
r.summary_totals.coverage_branches_hit as f64
/ r.summary_totals.coverage_branches_found as f64
* 100.0
)
},
);
let cov_no_data_notice = if has_coverage {
String::new()
} else {
String::from(
r#"<div class="empty-state" style="margin-bottom:18px;padding:20px 24px;">
<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>
<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:6px 4px;margin-bottom:10px;">
<span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-right:4px;">Supported formats</span>
<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>
<span style="color:var(--muted);font-size:12px;">·</span>
<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>
<span style="color:var(--muted);font-size:12px;">·</span>
<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>
</div>
<div style="font-size:12px;color:var(--muted);">Provide the file via the web scan form or <code>--coverage-file</code> CLI flag.</div>
</div>"#,
)
};
let workspace_density_str = format!("{workspace_density:.1}");
let nonce = &csp_nonce;
let version = env!("CARGO_PKG_VERSION");
// Build the watched-dirs bar HTML. In Network Server mode show a locked notice instead
// of interactive controls — folder watching is managed by the host administrator.
let watched_dirs_html: String = if state.server_mode {
r#"<div class="watched-bar"><div class="watched-bar-left"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg><span class="watched-label">Watched Folders</span><div class="watched-chips"><span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span></div></div></div>"#.to_string()
} else {
let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
.to_string()
} else {
watched_dirs_list
.iter()
.fold(String::new(), |mut s, d| {
use std::fmt::Write as _;
let escaped =
d.replace('&', "&").replace('"', """).replace('<', "<");
write!(
s,
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>"#
).expect("write to String is infallible");
s
})
};
format!(
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>"#
)
};
// Build per-root SCOPE_DATA for instant JS scope switching (no API fetch on selection change).
let scope_data_json = build_scope_data_json(&state, latest_run.as_ref()).await;
let html = format!(
r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OxideSLOC | Test Metrics</title>
<link rel="icon" type="image/png" href="/images/logo/small-logo.png">
<style nonce="{nonce}">
:root {{
--radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
--line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
--nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
--oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
--info-bg:#eef3ff; --info-text:#4467d8;
}}
body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
*{{box-sizing:border-box;}} html,body{{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);}} body{{display:flex;flex-direction:column;}}
.background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
.background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
.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;}}
@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));}}}}
.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);}}
.top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
.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));}}
.brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
.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;}}
.nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
@media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
@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; }} }}
.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;}}
.nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
.theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
.theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
.theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
.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;}}
.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;}}
.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;}}
.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;}}
.settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
.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);}}
.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;}}
.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;}}
.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;}}
.scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
.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;}}
.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);}}
.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;}}
.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;}}
.tz-select:focus{{border-color:var(--oxide);}}
.page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
@media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
.panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
.muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
.summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
@media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
.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;}}
.stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
.stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
.stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
.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;}}
.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;line-height:1.6;white-space:normal;max-width:280px;pointer-events:none;opacity:0;transition:opacity .2s ease;z-index:200;}}
.stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
.stat-chip:hover .stat-chip-tip{{opacity:1;}}
.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);}}
.section-header:first-child{{margin-top:0;padding-top:0;border-top:none;}}
.chart-row{{display:grid;gap:18px;grid-template-columns:1fr 1fr;margin-bottom:18px;}}
@media(max-width:900px){{.chart-row{{grid-template-columns:1fr;}}}}
.chart-box{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
.chart-box-title{{font-size:12px;font-weight:800;color:var(--muted-2);text-transform:uppercase;letter-spacing:.06em;margin-bottom:12px;}}
.chart-canvas-wrap{{position:relative;height:280px;}}
.chart-no-data{{display:flex;flex-direction:column;align-items:center;justify-content:center;height:200px;border:1px dashed var(--line-strong);border-radius:10px;color:var(--muted);font-size:13px;gap:10px;}}
.chart-no-data svg{{opacity:0.35;}}
.chart-no-data-title{{font-weight:700;font-size:13px;color:var(--muted-2);}}
.chart-no-data-hint{{font-size:11px;color:var(--muted);text-align:center;max-width:220px;line-height:1.5;}}
.data-table{{width:100%;border-collapse:collapse;font-size:13px;}}
.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;}}
.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;}}
.data-table tr:last-child td{{border-bottom:none;}}
.data-table tbody tr:hover td{{background:var(--surface-2);}}
.num{{text-align:right!important;font-variant-numeric:tabular-nums;}}
.density-bar-wrap{{display:flex;align-items:center;gap:8px;}}
.density-bar{{height:6px;border-radius:3px;background:var(--oxide);opacity:0.75;min-width:2px;flex-shrink:0;}}
.cov-gauge-row{{display:grid!important;grid-template-columns:repeat(3,1fr)!important;gap:16px;margin-bottom:18px;}}
.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;}}
.cov-gauge-card:hover{{transform:translateY(-3px);box-shadow:0 10px 28px rgba(77,44,20,0.15);}}
.cov-gauge-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);}}
.cov-gauge-val{{font-size:32px;font-weight:900;line-height:1;}}
.cov-gauge-track{{height:8px;border-radius:4px;background:var(--line);overflow:hidden;}}
.cov-gauge-fill{{height:100%;border-radius:4px;transition:width .5s ease;}}
.cov-gauge-sub{{font-size:11px;color:var(--muted);}}
@media(max-width:700px){{.cov-gauge-row{{grid-template-columns:1fr!important;}}}}
.controls-row{{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:16px;}}
.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;}}
.chart-select:focus{{border-color:var(--accent);}}
.empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
.trend-canvas-wrap{{position:relative;height:260px;}}
.site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
.site-footer a{{color:var(--muted);}}
body.dark-theme .chart-box{{border-color:var(--line-strong);}}
.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;}}
.btn:hover{{background:var(--surface-2);}}
.scope-bar{{display:flex;align-items:center;gap:12px;background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:8px 12px;margin-bottom:14px;position:relative;z-index:1;flex-wrap:wrap;}}
.scope-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
.scope-sel-wrap{{display:flex;align-items:center;gap:10px;flex:1;flex-wrap:wrap;}}
.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;}}
.scope-sel:focus{{border-color:var(--accent);}}
body.dark-theme .scope-sel{{background:var(--surface);color:var(--text);}}
.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;}}
.watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
.watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
.watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
.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;}}
.watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
.watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
.watched-chip-rm:hover{{color:var(--oxide);}}
.watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
.watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
.watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
.cov-file-toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;}}
.cov-filter-tabs{{display:flex;gap:6px;flex-wrap:wrap;}}
.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;}}
.cov-tab.active,.cov-tab:hover{{background:var(--oxide);border-color:var(--oxide-2);color:#fff;}}
.cov-tab[data-tier="high"].active{{background:#2a6846;border-color:#1f5035;}}
.cov-tab[data-tier="mid"].active{{background:#b58a00;border-color:#9a7400;}}
.cov-tab[data-tier="low"].active,.cov-tab[data-tier="zero"].active{{background:#b23030;border-color:#8f2626;}}
.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;}}
.cov-file-search:focus{{border-color:var(--accent);}}
.cov-pct-badge{{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-variant-numeric:tabular-nums;}}
.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;}}
body.dark-theme .cov-file-search{{background:var(--surface);}}
.chart-box-header{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
.chart-expand-btn{{background:none;border:1px solid var(--line-strong);border-radius:6px;cursor:pointer;color:var(--muted);padding:4px 10px;font-size:13px;line-height:1;transition:background .13s,color .13s;flex-shrink:0;white-space:nowrap;}}
.chart-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
.chart-modal-overlay{{position:fixed;inset:0;background:rgba(0,0,0,0.55);z-index:9999;display:flex;align-items:center;justify-content:center;padding:24px;box-sizing:border-box;}}
.chart-modal{{background:var(--bg);border-radius:16px;padding:24px 28px;max-width:1200px;width:100%;max-height:88vh;overflow-y:auto;position:relative;box-shadow:0 24px 80px rgba(0,0,0,0.3);}}
.chart-modal-title{{font-size:15px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;color:var(--text);margin:0 0 2px;display:block;}}
.chart-modal-subtitle{{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 16px;display:block;letter-spacing:.02em;}}
.chart-modal-close{{position:absolute;top:14px;right:18px;background:none;border:none;font-size:22px;cursor:pointer;color:var(--text);line-height:1;padding:0;}}
.chart-modal-close:hover{{opacity:.7;}}
body.dark-theme .chart-modal{{background:var(--surface);}}
</style>
</head>
<body>
<div class="background-watermarks" aria-hidden="true">
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
</div>
<div class="code-particles" id="code-particles" aria-hidden="true"></div>
<div class="top-nav">
<div class="top-nav-inner">
<a class="brand" href="/">
<img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
<div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Test metrics</div></div>
</a>
<div class="nav-right">
<a class="nav-pill" href="/">Home</a>
<div class="nav-dropdown">
<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>
<div class="nav-dropdown-menu">
<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>
</div>
</div>
<a class="nav-pill" href="/compare-scans">Compare Scans</a>
<a class="nav-pill" href="/test-metrics" style="background:rgba(255,255,255,0.22);">Test Metrics</a>
<div class="nav-dropdown">
<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>
<div class="nav-dropdown-menu">
<a href="/integrations"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
</div>
</div>
<div class="server-status-wrap" id="server-status-wrap">
<div class="nav-pill server-online-pill" id="server-status-pill">
<span class="status-dot" id="status-dot"></span>
<span id="server-status-label">Server</span>
<span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
</div>
<div class="server-status-tip">
OxideSLOC is running — accessible on your network.
<span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
</div>
</div>
<button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
<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>
</button>
<button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
<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>
<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>
</button>
</div>
</div>
</div>
<div class="page">
{watched_dirs_html}
<div class="scope-bar">
<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>
<span class="scope-label">Scope</span>
<div class="scope-sel-wrap">
<select id="scope-root-sel" class="scope-sel"><option value="__all__">All projects</option></select>
<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);">
<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>
<select id="scope-sub-sel" class="scope-sel"><option value="">Entire project</option></select>
</div>
</div>
</div>
<div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
<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>
<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>
<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>
<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>
</div>
<div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
<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>
<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>
<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>
<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>
</div>
<div class="panel" id="viz-panel">
<div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">Visualizations</div>
<div class="chart-box" style="margin-bottom:18px;">
<div class="chart-box-header">
<div class="chart-box-title" style="margin-bottom:0;">Test Count Trend</div>
<div style="display:flex;gap:8px;align-items:center;">
<button class="chart-expand-btn" id="multi-compare-trend-btn" title="Open all scans in Multi-Scan Timeline" style="display:none;">⇌ Multi-Timeline</button>
<button class="chart-expand-btn" id="trend-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
</div>
</div>
<p style="font-size:13px;color:var(--muted);margin:0 0 14px;">Test definition count across all saved scans for the selected scope. Use <strong>Multi-Timeline</strong> to compare all scans side-by-side.</p>
<div class="chart-canvas-wrap trend-canvas-wrap"><canvas id="canvas-trend"></canvas></div>
<div id="trend-empty" class="empty-state" style="display:none;">No historical test data found. Run more scans to see trends.</div>
</div>
<div class="chart-row">
<div class="chart-box">
<div class="chart-box-header">
<div class="chart-box-title" style="margin-bottom:0;">Test Definitions by Language</div>
<button class="chart-expand-btn" id="tests-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
</div>
<div class="chart-canvas-wrap"><canvas id="canvas-tests"></canvas></div>
<div id="no-data-tests" class="chart-no-data" style="display:none;"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg><div class="chart-no-data-title">No test data</div><div class="chart-no-data-hint">Run a scan on a project with test files to see test definitions by language.</div></div>
</div>
<div class="chart-box">
<div class="chart-box-header">
<div class="chart-box-title" style="margin-bottom:0;">Test Density (per 1 000 code lines)</div>
<button class="chart-expand-btn" id="density-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
</div>
<div class="chart-canvas-wrap"><canvas id="canvas-density"></canvas></div>
<div id="no-data-density" class="chart-no-data" style="display:none;"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3v18h18"/><polyline points="7 16 11 11 15 14 19 8"/></svg><div class="chart-no-data-title">No density data</div><div class="chart-no-data-hint">Density requires detected test functions alongside code SLOC.</div></div>
</div>
</div>
<div class="chart-row">
<div class="chart-box">
<div class="chart-box-header">
<div class="chart-box-title" style="margin-bottom:0;">Assertions by Language</div>
<button class="chart-expand-btn" id="assertions-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
</div>
<div class="chart-canvas-wrap"><canvas id="canvas-assertions"></canvas></div>
<div id="no-data-assertions" class="chart-no-data" style="display:none;"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="9"/><line x1="9" y1="12" x2="15" y2="12"/><line x1="12" y1="9" x2="12" y2="15"/></svg><div class="chart-no-data-title">No assertion data</div><div class="chart-no-data-hint">No assertion calls detected in the current scope.</div></div>
</div>
<div class="chart-box" id="suites-chart-box">
<div class="chart-box-header">
<div class="chart-box-title" style="margin-bottom:0;">Test Suites by Language</div>
<button class="chart-expand-btn" id="suites-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
</div>
<div class="chart-canvas-wrap"><canvas id="canvas-suites"></canvas></div>
<div id="no-data-suites" class="chart-no-data" style="display:none;"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg><div class="chart-no-data-title">No suite data</div><div class="chart-no-data-hint">No test suite groupings detected in the current scope.</div></div>
</div>
</div>
<div class="chart-row">
<div class="chart-box">
<div class="chart-box-title">Test Files Breakdown</div>
<div class="chart-canvas-wrap" style="height:260px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-files"></canvas></div>
<div id="no-data-files" class="chart-no-data" style="display:none;"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="9"/><path d="M12 8v4l3 3"/></svg><div class="chart-no-data-title">No file data</div><div class="chart-no-data-hint">No files found in the current scope.</div></div>
</div>
<div class="chart-box">
<div class="chart-box-title">Test Composition</div>
<p style="font-size:11px;color:var(--muted);margin:0 0 10px;">Total counts: test functions, assertions, and suites workspace-wide.</p>
<div class="chart-canvas-wrap"><canvas id="canvas-composition"></canvas></div>
<div id="no-data-composition" class="chart-no-data" style="display:none;"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg><div class="chart-no-data-title">No composition data</div><div class="chart-no-data-hint">Run a scan to see test function, assertion, and suite counts.</div></div>
</div>
</div>
</div>
<div class="panel">
<h1>Test Metrics</h1>
<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>
<div class="section-header">Language Breakdown</div>
{cov_no_data_notice}
<div style="overflow-x:auto;">
<table class="data-table" id="lang-table">
<thead><tr>
<th>Language</th>
<th class="num">Test Fns</th>
<th class="num">Assertions</th>
<th class="num">Suites</th>
<th class="num">Code Lines</th>
<th class="num">Files</th>
<th class="num">Density / 1K</th>
<th>Relative Density</th>
</tr></thead>
<tbody id="lang-tbody"></tbody>
</table>
</div>
</div>
<div class="panel" id="cov-panel" style="display:none;">
<div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">LCOV Coverage Summary</div>
<div class="cov-gauge-row" id="cov-gauges">
<div class="cov-gauge-card">
<div class="cov-gauge-label">Line Coverage</div>
<div class="cov-gauge-val" id="cov-line-val" style="color:#2a6846;">{cov_line_pct_str}%</div>
<div class="cov-gauge-track"><div id="cov-line-bar" class="cov-gauge-fill" style="width:{cov_line_pct_str}%;background:#2a6846;"></div></div>
<div class="cov-gauge-sub">Lines hit / instrumented</div>
</div>
<div class="cov-gauge-card">
<div class="cov-gauge-label">Function Coverage</div>
<div class="cov-gauge-val" id="cov-fn-val" style="color:#1a6b96;">{cov_fn_pct_str}%</div>
<div class="cov-gauge-track"><div id="cov-fn-bar" class="cov-gauge-fill" style="width:{cov_fn_pct_str}%;background:#1a6b96;"></div></div>
<div class="cov-gauge-sub">Functions hit / found</div>
</div>
<div class="cov-gauge-card">
<div class="cov-gauge-label">Branch Coverage</div>
<div class="cov-gauge-val" id="cov-branch-val" style="color:#7a4fa0;">{cov_branch_pct_str}%</div>
<div class="cov-gauge-track"><div id="cov-branch-bar" class="cov-gauge-fill" style="width:{cov_branch_pct_str}%;background:#7a4fa0;"></div></div>
<div class="cov-gauge-sub">Branches hit / found</div>
</div>
</div>
<div class="chart-row">
<div class="chart-box">
<div class="chart-box-title">Line Coverage % by Language</div>
<div class="chart-canvas-wrap"><canvas id="canvas-cov"></canvas></div>
</div>
<div class="chart-box">
<div class="chart-box-title">Coverage Tier Distribution</div>
<div class="chart-canvas-wrap" style="height:280px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-cov-tiers"></canvas></div>
</div>
</div>
<div class="section-header" style="margin-top:24px;">Coverage File Detail</div>
<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>
<div class="cov-file-toolbar">
<div class="cov-filter-tabs" id="cov-filter-tabs">
<button class="cov-tab active" data-tier="all">All</button>
<button class="cov-tab" data-tier="zero">Uncovered (0%)</button>
<button class="cov-tab" data-tier="low">Low (<50%)</button>
<button class="cov-tab" data-tier="mid">Moderate (50–79%)</button>
<button class="cov-tab" data-tier="high">High (≥80%)</button>
</div>
<input type="search" id="cov-file-search" class="cov-file-search" placeholder="Filter by filename…">
</div>
<div style="overflow-x:auto;">
<table class="data-table" id="cov-file-table">
<thead><tr>
<th>File</th>
<th>Lang</th>
<th class="num">Line %</th>
<th class="num">Lines Hit / Found</th>
<th class="num">Fn %</th>
<th class="num">Fns Hit / Found</th>
</tr></thead>
<tbody id="cov-file-tbody"></tbody>
</table>
</div>
<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>
<div id="cov-file-count" style="text-align:right;font-size:11px;color:var(--muted);margin-top:8px;"></div>
</div>
</div>
<footer class="site-footer">
local code analysis - metrics, history and reports
· <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{version} — Mode: Server</em>
· Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
· <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
· <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
· <a href="/api-docs" rel="noopener">REST API</a>
</footer>
<script nonce="{nonce}">
(function() {{
// Theme
var b = document.body;
try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
var tgl = document.getElementById('theme-toggle');
if (tgl) tgl.addEventListener('click', function() {{
var d = b.classList.toggle('dark-theme');
try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
}});
// Watermarks
(function() {{
var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
if (!wms.length) return;
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),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;}});
}})();
// Code particles
(function() {{
var container = document.getElementById('code-particles');
if (!container) return;
var snippets = ['#[test]','def test_','@Test','it(\'should','func Test','describe(','TEST(','test_that(','expect(','assert_eq!','@Fact','it \"passes\"','test {{','Describe'];
for (var i = 0; i < 36; i++) {{
(function(idx) {{
var el = document.createElement('span');
el.className = 'code-particle';
el.textContent = snippets[idx % snippets.length];
var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
var rot = (Math.random() * 26 - 13).toFixed(1), 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);
}}
}})();
// Settings modal
(function() {{
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'}}];
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);}});}}
try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
var btn=document.getElementById('settings-btn');if(!btn)return;
var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
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>';
document.body.appendChild(m);
var g=document.getElementById('scheme-grid');
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);}});
var cl=document.getElementById('settings-close');
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');}});
if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
}})();
// Watched folder picker
(function() {{
var btn = document.getElementById('add-watched-btn');
if (!btn) return;
btn.addEventListener('click', function() {{
fetch('/pick-directory?kind=reports')
.then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
.then(function(data) {{
if (!data.cancelled && data.selected_path) {{
var form = document.createElement('form');
form.method = 'POST';
form.action = '/watched-dirs/add';
var ri = document.createElement('input');
ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
var fi = document.createElement('input');
fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
form.appendChild(ri); form.appendChild(fi);
document.body.appendChild(form);
form.submit();
}}
}})
.catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
}});
}})();
}})();
</script>
<script src="/static/chart.js" nonce="{nonce}"></script>
<script nonce="{nonce}">
(function() {{
var SCOPE_DATA = {scope_data_json};
var currentRoot = '__all__';
var currentSub = '';
var testsChart = null, densityChart = null, covChart = null, tierChart = null, trendChart = null;
var assertionsChart = null, suitesChart = null, filesChart = null, compositionChart = null;
var ALL_CHARTS = [];
var currentLangTests = [];
var currentTrendPts = [];
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(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}}
function fmtFull(n){{return Number(n).toLocaleString();}}
function isDark(){{return document.body.classList.contains('dark-theme');}}
function clr(){{return isDark()?'rgba(245,236,230,0.12)':'rgba(67,52,45,0.10)';}}
function txtClr(){{return isDark()?'#c7b7aa':'#7b675b';}}
var PALETTE=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#D0743C','#5BA8A0'];
function makeDlPlugin(fmtFn, anchor) {{
return {{
afterDatasetsDraw: function(chart) {{
var ctx = chart.ctx;
var tc = txtClr();
chart.data.datasets.forEach(function(ds, di) {{
var meta = chart.getDatasetMeta(di);
meta.data.forEach(function(el, idx) {{
var label = fmtFn(ds.data[idx], di, idx);
if (label == null || label === '') return;
ctx.save();
ctx.font = '600 11px Inter,ui-sans-serif,sans-serif';
ctx.fillStyle = tc;
if (anchor === 'top') {{
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillText(String(label), el.x, el.y - 5);
}} else {{
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(String(label), el.x + 5, el.y);
}}
ctx.restore();
}});
}});
}}
}};
}}
function makeTmOverlay(title, subtitle, h) {{
var overlay = document.createElement('div');
overlay.className = 'chart-modal-overlay';
var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
var ch = Math.min(h || 560, maxH);
var subHtml = subtitle ? '<span class="chart-modal-subtitle">' + subtitle + '</span>' : '';
overlay.innerHTML = '<div class="chart-modal" style="max-width:1200px;"><button class="chart-modal-close" aria-label="Close">×</button><span class="chart-modal-title">' + title + '</span>' + subHtml + '<div style="position:relative;width:100%;height:' + ch + 'px;"><canvas id="tm-modal-canvas"></canvas></div></div>';
document.body.appendChild(overlay);
overlay.querySelector('.chart-modal-close').addEventListener('click', function(){{ document.body.removeChild(overlay); }});
overlay.addEventListener('click', function(e){{ if (e.target === overlay) document.body.removeChild(overlay); }});
return document.getElementById('tm-modal-canvas');
}}
function getDataset() {{
var r = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
if (currentSub && r.submodules && r.submodules[currentSub]) return r.submodules[currentSub];
return r;
}}
function destroyChart(c) {{ if (c) {{ var idx = ALL_CHARTS.indexOf(c); if (idx >= 0) ALL_CHARTS.splice(idx, 1); c.destroy(); }} return null; }}
function showNoData(id, show) {{
var el = document.getElementById(id);
if (!el) return;
var wrap = el.previousElementSibling;
el.style.display = show ? '' : 'none';
if (wrap && wrap.classList.contains('chart-canvas-wrap')) wrap.style.display = show ? 'none' : '';
}}
function renderTestCharts(D) {{
currentLangTests = D || [];
testsChart = destroyChart(testsChart);
densityChart = destroyChart(densityChart);
if (!D || !D.length) {{
showNoData('no-data-tests', true);
showNoData('no-data-density', true);
return;
}}
showNoData('no-data-tests', false);
showNoData('no-data-density', false);
var top15 = D.slice(0, 15);
var canvas1 = document.getElementById('canvas-tests');
if (canvas1) {{
testsChart = new Chart(canvas1, {{
type: 'bar',
data: {{
labels: top15.map(function(d){{ return d.lang; }}),
datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
}},
options: {{
responsive: true, maintainAspectRatio: false, indexAxis: 'y',
layout: {{ padding: {{ right: 64 }} }},
plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
scales: {{
x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }},
y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
}}
}},
plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
}});
ALL_CHARTS.push(testsChart);
}}
var topD = top15.slice().sort(function(a,b){{ return b.density - a.density; }});
var canvas2 = document.getElementById('canvas-density');
if (canvas2) {{
densityChart = new Chart(canvas2, {{
type: 'bar',
data: {{
labels: topD.map(function(d){{ return d.lang; }}),
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 }}]
}},
options: {{
responsive: true, maintainAspectRatio: false, indexAxis: 'y',
layout: {{ padding: {{ right: 64 }} }},
plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
scales: {{
x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v.toFixed(1); }} }} }},
y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
}}
}},
plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
}});
ALL_CHARTS.push(densityChart);
}}
}}
function renderAssertionsChart(D) {{
assertionsChart = destroyChart(assertionsChart);
if (!D || !D.length) {{ showNoData('no-data-assertions', true); return; }}
var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
var canvas = document.getElementById('canvas-assertions');
if (!canvas || !top15.length) {{ showNoData('no-data-assertions', true); return; }}
showNoData('no-data-assertions', false);
assertionsChart = new Chart(canvas, {{
type: 'bar',
data: {{
labels: top15.map(function(d){{ return d.lang; }}),
datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
}},
options: {{
responsive: true, maintainAspectRatio: false, indexAxis: 'y',
layout: {{ padding: {{ right: 64 }} }},
plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
scales: {{
x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }},
y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
}}
}},
plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
}});
ALL_CHARTS.push(assertionsChart);
}}
function renderSuitesChart(D) {{
suitesChart = destroyChart(suitesChart);
if (!D || !D.length) {{ showNoData('no-data-suites', true); return; }}
var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
var canvas = document.getElementById('canvas-suites');
if (!canvas || !top15.length) {{ showNoData('no-data-suites', true); return; }}
showNoData('no-data-suites', false);
suitesChart = new Chart(canvas, {{
type: 'bar',
data: {{
labels: top15.map(function(d){{ return d.lang; }}),
datasets: [{{ label: 'Test Suites', data: top15.map(function(d){{ return d.suites; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+6) % PALETTE.length]; }}), borderRadius: 4 }}]
}},
options: {{
responsive: true, maintainAspectRatio: false, indexAxis: 'y',
layout: {{ padding: {{ right: 64 }} }},
plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
scales: {{
x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }},
y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
}}
}},
plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
}});
ALL_CHARTS.push(suitesChart);
}}
function renderFilesChart(totals) {{
filesChart = destroyChart(filesChart);
var canvas = document.getElementById('canvas-files');
if (!canvas) return;
var testF = totals.test_files || 0;
var totalF = totals.total_files || 0;
var nonTest = Math.max(0, totalF - testF);
if (totalF === 0) {{ showNoData('no-data-files', true); return; }}
showNoData('no-data-files', false);
var dark = isDark();
filesChart = new Chart(canvas, {{
type: 'doughnut',
data: {{
labels: ['Test Files', 'Non-Test Files'],
datasets: [{{ data: [testF, nonTest], backgroundColor: ['#C45C10', dark ? '#524238' : '#e6d0bf'], borderWidth: 2, borderColor: dark ? '#1e1e1e' : '#f5efe8' }}]
}},
options: {{
responsive: true, maintainAspectRatio: false, cutout: '62%',
plugins: {{
legend: {{ position: 'bottom', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16 }} }},
tooltip: {{ callbacks: {{ label: function(ctx) {{
var v = ctx.parsed, pct = totalF > 0 ? (v / totalF * 100).toFixed(1) : '0';
return ' ' + fmtFull(v) + ' files (' + pct + '%)';
}} }} }}
}}
}}
}});
ALL_CHARTS.push(filesChart);
}}
function renderCompositionChart(totals) {{
compositionChart = destroyChart(compositionChart);
var canvas = document.getElementById('canvas-composition');
if (!canvas) return;
var tc = totals.test_count || 0, ac = totals.assertions || 0, sc = totals.suites || 0;
if (tc === 0 && ac === 0 && sc === 0) {{ showNoData('no-data-composition', true); return; }}
showNoData('no-data-composition', false);
compositionChart = new Chart(canvas, {{
type: 'bar',
data: {{
labels: ['Test Functions', 'Assertions', 'Test Suites'],
datasets: [{{ label: 'Count', data: [tc, ac, sc], backgroundColor: ['#C45C10', '#2A6846', '#4472C4'], borderRadius: 6 }}]
}},
options: {{
responsive: true, maintainAspectRatio: false,
layout: {{ padding: {{ top: 22 }} }},
plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y); }} }} }} }},
scales: {{
x: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }},
y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }}
}}
}},
plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'top')]
}});
ALL_CHARTS.push(compositionChart);
}}
function renderCovCharts(covD, tiers) {{
covChart = destroyChart(covChart);
tierChart = destroyChart(tierChart);
var covCanvas = document.getElementById('canvas-cov');
if (covCanvas && covD && covD.length) {{
covChart = new Chart(covCanvas, {{
type: 'bar',
data: {{
labels: covD.map(function(d){{ return d.lang; }}),
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 }}]
}},
options: {{
responsive: true, maintainAspectRatio: false, indexAxis: 'y',
plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + ctx.parsed.x.toFixed(1) + '%'; }} }} }} }},
scales: {{
x: {{ min: 0, max: 100, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v + '%'; }} }} }},
y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
}}
}}
}});
ALL_CHARTS.push(covChart);
}}
var tierCanvas = document.getElementById('canvas-cov-tiers');
if (tierCanvas && tiers) {{
var total = (tiers.high || 0) + (tiers.mid || 0) + (tiers.low || 0);
tierChart = new Chart(tierCanvas, {{
type: 'doughnut',
data: {{
labels: ['High (≥80%)', 'Moderate (50–79%)', 'Low (<50%)'],
datasets: [{{ data: [tiers.high || 0, tiers.mid || 0, tiers.low || 0], backgroundColor: ['#2A6846', '#D4A017', '#B23030'], borderWidth: 2, borderColor: isDark() ? '#1e1e1e' : '#f5efe8' }}]
}},
options: {{
responsive: true, maintainAspectRatio: false, cutout: '62%',
plugins: {{
legend: {{ position: 'bottom', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16 }} }},
tooltip: {{ callbacks: {{ label: function(ctx) {{
var v = ctx.parsed, pct = total > 0 ? (v / total * 100).toFixed(1) : '0';
return ' ' + v + ' file' + (v !== 1 ? 's' : '') + ' (' + pct + '%)';
}} }} }}
}}
}}
}});
ALL_CHARTS.push(tierChart);
}}
}}
function buildLangTable(D) {{
var tbody = document.getElementById('lang-tbody');
if (!tbody) return;
if (!D || !D.length) {{
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>';
return;
}}
var maxDensity = Math.max.apply(null, D.map(function(d){{ return d.density; }})) || 1;
tbody.innerHTML = D.map(function(d) {{
var barW = Math.round(d.density / maxDensity * 120);
return '<tr>' +
'<td><strong>' + d.lang + '</strong></td>' +
'<td class="num">' + fmt(d.tests) + '</td>' +
'<td class="num">' + fmt(d.assertions || 0) + '</td>' +
'<td class="num">' + fmt(d.suites || 0) + '</td>' +
'<td class="num">' + fmt(d.code) + '</td>' +
'<td class="num">' + fmt(d.files) + '</td>' +
'<td class="num">' + d.density.toFixed(2) + '</td>' +
'<td><div class="density-bar-wrap"><div class="density-bar" style="width:' + barW + 'px;"></div></div></td>' +
'</tr>';
}}).join('');
}}
var covFileData = [];
var covFileTier = 'all';
var covFileSearch = '';
function pctBadge(pct) {{
var color = pct >= 80 ? '#2a6846' : pct >= 50 ? '#b58a00' : '#b23030';
var bg = pct >= 80 ? 'rgba(42,104,70,0.12)' : pct >= 50 ? 'rgba(181,138,0,0.12)' : 'rgba(178,48,48,0.12)';
return '<span class="cov-pct-badge" style="background:' + bg + ';color:' + color + ';border:1px solid ' + color + '40;">' + pct.toFixed(1) + '%</span>';
}}
function buildCovFileTable() {{
var tbody = document.getElementById('cov-file-tbody');
var empty = document.getElementById('cov-file-empty');
var count = document.getElementById('cov-file-count');
if (!tbody) return;
var srch = covFileSearch.toLowerCase();
var filtered = covFileData.filter(function(f) {{
if (covFileTier === 'zero' && f.line_pct > 0) return false;
if (covFileTier === 'low' && (f.line_pct === 0 || f.line_pct >= 50)) return false;
if (covFileTier === 'mid' && (f.line_pct < 50 || f.line_pct >= 80)) return false;
if (covFileTier === 'high' && f.line_pct < 80) return false;
if (srch && f.rel.toLowerCase().indexOf(srch) < 0) return false;
return true;
}});
if (!filtered.length) {{
tbody.innerHTML = '';
if (empty) empty.style.display = '';
if (count) count.textContent = '';
return;
}}
if (empty) empty.style.display = 'none';
var shown = Math.min(filtered.length, 500);
if (count) count.textContent = shown + ' of ' + filtered.length + ' file' + (filtered.length !== 1 ? 's' : '') + (filtered.length > 500 ? ' (showing first 500)' : '');
tbody.innerHTML = filtered.slice(0, 500).map(function(f) {{
var fnCol = f.fn_pct < 0
? '<td class="num" style="color:var(--muted);font-size:11px;">—</td><td class="num" style="color:var(--muted);font-size:11px;">—</td>'
: '<td class="num">' + pctBadge(f.fn_pct) + '</td><td class="num" style="color:var(--muted);font-size:11px;">' + f.fhit + ' / ' + f.ffound + '</td>';
return '<tr>' +
'<td class="cov-file-path" title="' + f.rel.replace(/"/g, '"') + '">' + f.rel + '</td>' +
'<td style="color:var(--muted);font-size:11px;white-space:nowrap;">' + f.lang + '</td>' +
'<td class="num">' + pctBadge(f.line_pct) + '</td>' +
'<td class="num" style="color:var(--muted);font-size:11px;">' + f.lhit + ' / ' + f.lfound + '</td>' +
fnCol +
'</tr>';
}}).join('');
}}
(function() {{
var tabs = document.getElementById('cov-filter-tabs');
if (tabs) {{
tabs.addEventListener('click', function(e) {{
var btn = e.target.closest('.cov-tab');
if (!btn) return;
Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(t) {{ t.classList.remove('active'); }});
btn.classList.add('active');
covFileTier = btn.getAttribute('data-tier');
buildCovFileTable();
}});
}}
var srch = document.getElementById('cov-file-search');
if (srch) {{
srch.addEventListener('input', function() {{
covFileSearch = this.value;
buildCovFileTable();
}});
}}
}})();
function updateCovGauges(t) {{
var lp = t.cov_line || '0', fp = t.cov_fn || '0', bp = t.cov_branch || '0';
var el;
if ((el = document.getElementById('cov-line-val'))) el.textContent = lp + '%';
if ((el = document.getElementById('cov-line-bar'))) el.style.width = lp + '%';
if ((el = document.getElementById('cov-fn-val'))) el.textContent = fp + '%';
if ((el = document.getElementById('cov-fn-bar'))) el.style.width = fp + '%';
if ((el = document.getElementById('cov-branch-val'))) el.textContent = bp + '%';
if ((el = document.getElementById('cov-branch-bar'))) el.style.width = bp + '%';
}}
function applyScope() {{
var d = getDataset();
var t = d.totals;
var el;
if ((el = document.getElementById('chip-total'))) el.textContent = fmt(t.test_count);
if ((el = document.getElementById('chip-assertions'))) el.textContent = fmt(t.assertions);
if ((el = document.getElementById('chip-suites'))) el.textContent = fmt(t.suites);
if ((el = document.getElementById('chip-test-files'))) el.textContent = fmt(t.test_files) + ' / ' + fmt(t.total_files);
if ((el = document.getElementById('chip-density'))) el.textContent = t.density_str;
if ((el = document.getElementById('chip-most'))) el.textContent = t.most_tested;
if ((el = document.getElementById('chip-langs'))) el.textContent = fmt(t.langs_with_tests);
if ((el = document.getElementById('chip-cov-pct'))) el.textContent = t.cov_line + '%';
renderTestCharts(d.lang_tests);
renderAssertionsChart(d.lang_tests);
renderSuitesChart(d.lang_tests);
renderFilesChart(t);
renderCompositionChart(t);
buildLangTable(d.lang_tests);
var covPanel = document.getElementById('cov-panel');
if (covPanel) covPanel.style.display = d.has_coverage ? '' : 'none';
if (d.has_coverage) {{
renderCovCharts(d.cov, d.cov_tiers);
updateCovGauges(t);
covFileData = d.file_cov || [];
covFileTier = 'all';
covFileSearch = '';
var tabs = document.getElementById('cov-filter-tabs');
if (tabs) Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(tb) {{ tb.classList.toggle('active', tb.getAttribute('data-tier') === 'all'); }});
var srch = document.getElementById('cov-file-search');
if (srch) srch.value = '';
buildCovFileTable();
}}
loadTrend();
}}
// Populate scope-root-sel from SCOPE_DATA keys
(function() {{
var sel = document.getElementById('scope-root-sel');
if (!sel) return;
Object.keys(SCOPE_DATA).forEach(function(k) {{
if (k === '__all__') return;
var o = document.createElement('option'); o.value = k; o.textContent = k; sel.appendChild(o);
}});
}})();
document.getElementById('scope-root-sel').addEventListener('change', function() {{
currentRoot = this.value;
currentSub = '';
var rootData = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
var subNames = rootData && rootData.submodules ? Object.keys(rootData.submodules) : [];
var subWrap = document.getElementById('scope-sub-wrap');
var subSel = document.getElementById('scope-sub-sel');
subSel.innerHTML = '<option value="">Entire project</option>';
if (subNames.length) {{
subNames.forEach(function(s) {{ var o = document.createElement('option'); o.value = s; o.textContent = s; subSel.appendChild(o); }});
subWrap.style.display = 'flex';
}} else {{
subWrap.style.display = 'none';
}}
applyScope();
}});
document.getElementById('scope-sub-sel').addEventListener('change', function() {{
currentSub = this.value;
applyScope();
}});
function buildTrend(data) {{
var trendCanvas = document.getElementById('canvas-trend');
var trendEmpty = document.getElementById('trend-empty');
var pts = data.filter(function(d){{ return d.test_count > 0 || data.some(function(x){{ return x.test_count > 0; }}); }});
pts = pts.slice().reverse();
currentTrendPts = pts;
if (!pts.length) {{
if (trendCanvas) trendCanvas.style.display = 'none';
if (trendEmpty) trendEmpty.style.display = '';
return;
}}
if (trendCanvas) trendCanvas.style.display = '';
if (trendEmpty) trendEmpty.style.display = 'none';
trendChart = destroyChart(trendChart);
if (!trendCanvas) return;
trendChart = new Chart(trendCanvas, {{
type: 'line',
data: {{
labels: pts.map(function(d){{ return d.timestamp ? d.timestamp.slice(0,10) : d.run_id_short; }}),
datasets: [{{
label: 'Test Definitions',
data: pts.map(function(d){{ return d.test_count; }}),
borderColor: '#C45C10',
backgroundColor: 'rgba(196,92,16,0.10)',
pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : '#C45C10'; }}),
pointRadius: 5, fill: true, tension: 0.3
}}]
}},
options: {{
responsive: true, maintainAspectRatio: false,
layout: {{ padding: {{ top: 22 }} }},
plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y) + ' test defs'; }} }} }} }},
scales: {{
x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:10}}, maxRotation:35 }} }},
y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }}
}}
}},
plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'top')]
}});
ALL_CHARTS.push(trendChart);
}}
// ── Full View expand buttons ──────────────────────────────────────────────
(function() {{
var btn = document.getElementById('tests-expand-btn');
if (!btn) return;
btn.addEventListener('click', function() {{
var D = currentLangTests;
if (!D || !D.length) return;
var top15 = D.slice(0, 15);
var h = Math.max(320, top15.length * 36 + 80);
var canvas = makeTmOverlay('Test Definitions by Language — Full View', top15.length + ' languages', h);
if (!canvas) return;
new Chart(canvas, {{
type: 'bar',
data: {{
labels: top15.map(function(d){{ return d.lang; }}),
datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
}},
options: {{
responsive: true, maintainAspectRatio: false, indexAxis: 'y',
layout: {{ padding: {{ right: 72 }} }},
plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
scales: {{
x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmt(v); }} }} }},
y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
}}
}},
plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
}});
}});
}})();
(function() {{
var btn = document.getElementById('density-expand-btn');
if (!btn) return;
btn.addEventListener('click', function() {{
var D = currentLangTests;
if (!D || !D.length) return;
var topD = D.slice().sort(function(a,b){{ return b.density - a.density; }}).slice(0, 15);
var h = Math.max(320, topD.length * 36 + 80);
var canvas = makeTmOverlay('Test Density (per 1 000 code lines) — Full View', topD.length + ' languages', h);
if (!canvas) return;
new Chart(canvas, {{
type: 'bar',
data: {{
labels: topD.map(function(d){{ return d.lang; }}),
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 }}]
}},
options: {{
responsive: true, maintainAspectRatio: false, indexAxis: 'y',
layout: {{ padding: {{ right: 72 }} }},
plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
scales: {{
x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return v.toFixed(1); }} }} }},
y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
}}
}},
plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
}});
}});
}})();
(function() {{
var btn = document.getElementById('trend-expand-btn');
if (!btn) return;
btn.addEventListener('click', function() {{
var pts = currentTrendPts;
if (!pts || !pts.length) return;
var canvas = makeTmOverlay('Test Count Trend — Full View', pts.length + ' scan' + (pts.length !== 1 ? 's' : ''), 420);
if (!canvas) return;
new Chart(canvas, {{
type: 'line',
data: {{
labels: pts.map(function(d){{ return d.timestamp ? d.timestamp.slice(0,10) : d.run_id_short; }}),
datasets: [{{
label: 'Test Definitions',
data: pts.map(function(d){{ return d.test_count; }}),
borderColor: '#C45C10',
backgroundColor: 'rgba(196,92,16,0.10)',
pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : '#C45C10'; }}),
pointRadius: 5, fill: true, tension: 0.3
}}]
}},
options: {{
responsive: true, maintainAspectRatio: false,
layout: {{ padding: {{ top: 22 }} }},
plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y) + ' test defs'; }} }} }} }},
scales: {{
x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, maxRotation:35 }} }},
y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmt(v); }} }} }}
}}
}},
plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'top')]
}});
}});
}})();
(function() {{
var btn = document.getElementById('assertions-expand-btn');
if (!btn) return;
btn.addEventListener('click', function() {{
var D = currentLangTests;
if (!D || !D.length) return;
var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
if (!top15.length) return;
var h = Math.max(320, top15.length * 36 + 80);
var canvas = makeTmOverlay('Assertions by Language — Full View', top15.length + ' languages', h);
if (!canvas) return;
new Chart(canvas, {{
type: 'bar',
data: {{
labels: top15.map(function(d){{ return d.lang; }}),
datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
}},
options: {{
responsive: true, maintainAspectRatio: false, indexAxis: 'y',
layout: {{ padding: {{ right: 72 }} }},
plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
scales: {{
x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmt(v); }} }} }},
y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
}}
}},
plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
}});
}});
}})();
(function() {{
var btn = document.getElementById('suites-expand-btn');
if (!btn) return;
btn.addEventListener('click', function() {{
var D = currentLangTests;
if (!D || !D.length) return;
var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
if (!top15.length) return;
var h = Math.max(320, top15.length * 36 + 80);
var canvas = makeTmOverlay('Test Suites by Language — Full View', top15.length + ' languages', h);
if (!canvas) return;
new Chart(canvas, {{
type: 'bar',
data: {{
labels: top15.map(function(d){{ return d.lang; }}),
datasets: [{{ label: 'Test Suites', data: top15.map(function(d){{ return d.suites; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+6) % PALETTE.length]; }}), borderRadius: 4 }}]
}},
options: {{
responsive: true, maintainAspectRatio: false, indexAxis: 'y',
layout: {{ padding: {{ right: 72 }} }},
plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
scales: {{
x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmt(v); }} }} }},
y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
}}
}},
plugins: [makeDlPlugin(function(v){{ return fmt(v); }}, 'end')]
}});
}});
}})();
function loadTrend() {{
var url = '/api/metrics/history?limit=100';
if (currentRoot !== '__all__') url += '&root=' + encodeURIComponent(currentRoot);
fetch(url).then(function(r){{ return r.json(); }}).then(function(data){{
buildTrend(data);
// Show Multi-Timeline button when >= 2 scans exist for the selected project.
var btn = document.getElementById('multi-compare-trend-btn');
if (btn) {{
var ids = data.filter(function(d){{ return d.run_id; }}).map(function(d){{ return d.run_id; }});
if (ids.length >= 2) {{
btn.style.display = '';
btn.onclick = function() {{
// Reverse so oldest first (API returns newest first).
var sorted = ids.slice().reverse();
if (sorted.length === 2) {{
window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
}} else {{
window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
}}
}};
}} else {{
btn.style.display = 'none';
}}
}}
}}).catch(function(){{
var trendEmpty = document.getElementById('trend-empty');
if (trendEmpty) {{ trendEmpty.style.display = ''; trendEmpty.textContent = 'Failed to load trend data.'; }}
}});
}}
// Re-render charts on theme toggle
document.getElementById('theme-toggle') && document.getElementById('theme-toggle').addEventListener('click', function() {{
setTimeout(function() {{
ALL_CHARTS.forEach(function(c) {{
if (c && c.options && c.options.scales) {{
Object.values(c.options.scales).forEach(function(ax) {{
if (ax.grid) ax.grid.color = clr();
if (ax.ticks) ax.ticks.color = txtClr();
}});
c.update();
}}
}});
}}, 80);
}});
applyScope();
}})();
</script>
<script nonce="{nonce}">(function(){{var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';if(lbl)lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{version} — Mode: '+(isServer?'Network Server':'Local');function setDot(ms){{if(!dot)return;if(ms<100){{dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}}else if(ms<300){{dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}}else{{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}}function doPing(){{var t0=performance.now();fetch('/healthz',{{cache:'no-store'}}).then(function(){{var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}}).catch(function(){{if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}});}}doPing();setInterval(doPing,5000);}})();</script>
</body>
</html>"#,
);
Html(html).into_response()
}
// ── Embeddable widget ─────────────────────────────────────────────────────────
// Protected. Returns a self-contained HTML page suitable for iframing inside
// Jenkins build summaries, Confluence iframe macros, or Jira panels.
//
// GET /embed/summary?run_id=<uuid>&theme=dark
#[derive(Deserialize)]
struct EmbedQuery {
run_id: Option<String>,
theme: Option<String>,
}
async fn embed_handler(
State(state): State<AppState>,
axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
Query(query): Query<EmbedQuery>,
) -> Response {
let entry = {
let reg = state.registry.lock().await;
query.run_id.as_ref().map_or_else(
|| reg.entries.first().cloned(),
|id| reg.find_by_run_id(id).cloned(),
)
};
let Some(entry) = entry else {
return Html(
"<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
.to_string(),
)
.into_response();
};
let dark = query.theme.as_deref() == Some("dark");
let languages: Vec<(String, u64, u64)> = entry
.json_path
.as_ref()
.and_then(|p| read_json(p).ok())
.map(|run| {
run.totals_by_language
.iter()
.map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
.collect()
})
.unwrap_or_default();
Html(render_embed_widget(&entry, &languages, dark, &csp_nonce)).into_response()
}
fn render_embed_widget(
entry: &RegistryEntry,
languages: &[(String, u64, u64)],
dark: bool,
csp_nonce: &str,
) -> String {
let s = &entry.summary;
let total = s.code_lines + s.comment_lines + s.blank_lines;
let code_pct = s
.code_lines
.checked_mul(100)
.and_then(|n| n.checked_div(total))
.unwrap_or(0);
let (bg, fg, surface, muted, border) = if dark {
("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
} else {
("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
};
let mut lang_rows = String::new();
for (name, files, code) in languages {
write!(
lang_rows,
"<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
escape_html(name),
format_number(*files),
format_number(*code),
)
.ok();
}
let lang_table = if lang_rows.is_empty() {
String::new()
} else {
format!(
"<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
)
};
let run_short = &entry.run_id[..entry.run_id.len().min(8)];
let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
let project_esc = escape_html(&entry.project_label);
let code_lines = format_number(s.code_lines);
let comment_lines = format_number(s.comment_lines);
let files = format_number(s.files_analyzed);
let code_raw = s.code_lines;
let comment_raw = s.comment_lines;
let blank_raw = s.blank_lines;
format!(
r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>OxideSLOC — {project_esc}</title>
<script src="/static/chart.js"></script>
<style nonce="{csp_nonce}">
*{{box-sizing:border-box;margin:0;padding:0}}
body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
.sub{{color:{muted};font-size:11px;margin-bottom:10px}}
.cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
.card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
.card .v{{font-size:18px;font-weight:700}}
.card .l{{color:{muted};font-size:10px;margin-top:2px}}
.row{{display:flex;gap:12px;align-items:flex-start}}
.pie{{width:120px;height:120px;flex-shrink:0}}
.lt{{border-collapse:collapse;width:100%;flex:1}}
.lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
.lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
.n{{text-align:right}}
.footer{{margin-top:10px;color:{muted};font-size:10px}}
</style>
</head>
<body>
<h2>{project_esc}</h2>
<div class="sub">{timestamp} · run {run_short}</div>
<div class="cards">
<div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
<div class="card"><div class="v">{files}</div><div class="l">files</div></div>
<div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
<div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
</div>
<div class="row">
<canvas class="pie" id="c"></canvas>
{lang_table}
</div>
<div class="footer">oxide-sloc</div>
<script nonce="{csp_nonce}">
new Chart(document.getElementById('c'),{{
type:'doughnut',
data:{{
labels:['Code','Comments','Blank'],
datasets:[{{
data:[{code_raw},{comment_raw},{blank_raw}],
backgroundColor:['#4a78ee','#b35428','#aaa'],
borderWidth:0
}}]
}},
options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
}});
</script>
</body>
</html>"#
)
}
/// Returns a process-wide mutex unique to `dir`, so that two requests writing
/// artifacts into the *same* output directory (e.g. re-ingesting an identical
/// `run_id`) serialize instead of corrupting each other's files. Directories that
/// differ never contend, so legitimate parallel analyses keep their throughput.
fn output_dir_lock(dir: &Path) -> Arc<std::sync::Mutex<()>> {
static LOCKS: OnceLock<std::sync::Mutex<HashMap<PathBuf, Arc<std::sync::Mutex<()>>>>> =
OnceLock::new();
let map = LOCKS.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
let mut guard = map
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
guard
.entry(dir.to_path_buf())
.or_insert_with(|| Arc::new(std::sync::Mutex::new(())))
.clone()
}
#[allow(clippy::too_many_lines)]
fn persist_run_artifacts(
run: &sloc_core::AnalysisRun,
report_html: &str,
run_dir: &Path,
report_title: &str,
file_stem: &str,
result_context: RunResultContext,
) -> Result<(RunArtifacts, PendingPdf)> {
// Serialize concurrent writers targeting this same output directory so their
// file writes cannot interleave and corrupt one another.
let dir_lock = output_dir_lock(run_dir);
let _dir_guard = dir_lock
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
// Root dir + organised subdirectories.
let html_dir = run_dir.join("html");
let pdf_dir = run_dir.join("pdf");
let excel_dir = run_dir.join("excel");
let json_dir = run_dir.join("json");
let submodules_dir = run_dir.join("submodules");
for dir in &[
run_dir,
&html_dir,
&pdf_dir,
&excel_dir,
&json_dir,
&submodules_dir,
] {
fs::create_dir_all(dir)
.with_context(|| format!("failed to create directory {}", dir.display()))?;
}
// HTML report in html/.
let html_path = {
let path = html_dir.join(format!("report_{file_stem}.html"));
fs::write(&path, report_html)
.with_context(|| format!("failed to write HTML report to {}", path.display()))?;
Some(path)
};
// JSON result in json/.
let json_path = {
let path = json_dir.join(format!("result_{file_stem}.json"));
let json = serde_json::to_string_pretty(run)
.context("failed to serialize analysis run to JSON")?;
fs::write(&path, json)
.with_context(|| format!("failed to write JSON result to {}", path.display()))?;
Some(path)
};
// PDF in pdf/.
let (pdf_path, pending_pdf) = {
let pdf_dest = pdf_dir.join(format!("report_{file_stem}.pdf"));
match write_pdf_from_run(run, &pdf_dest) {
Ok(()) => {
eprintln!(
"[oxide-sloc][pdf] native PDF written to {}",
pdf_dest.display()
);
(Some(pdf_dest), None)
}
Err(native_err) => {
eprintln!(
"[oxide-sloc][pdf] native PDF failed ({native_err:#}), scheduling HTML->browser fallback"
);
let source_html_path = html_path
.as_ref()
.expect("html_path always Some here")
.clone();
let pending = Some((source_html_path, pdf_dest.clone(), false));
(Some(pdf_dest), pending)
}
}
};
// CSV and XLSX in excel/.
let csv_path = {
let path = excel_dir.join(format!("report_{file_stem}.csv"));
if let Err(e) = sloc_report::write_csv(run, &path) {
eprintln!("[oxide-sloc] CSV write failed (non-fatal): {e:#}");
None
} else {
Some(path)
}
};
let xlsx_path = {
let path = excel_dir.join(format!("report_{file_stem}.xlsx"));
if let Err(e) = sloc_report::write_xlsx(run, &path) {
eprintln!("[oxide-sloc] XLSX write failed (non-fatal): {e:#}");
None
} else {
Some(path)
}
};
// Scan config in json/.
let scan_config_path = Some(json_dir.join(format!("scan-config_{file_stem}.json")));
// Eagerly generate sub-reports before index.html so relative links work.
if run.effective_configuration.discovery.submodule_breakdown {
let run_id = &run.tool.run_id;
for s in &run.submodule_summaries {
build_submodule_row(s, run, run_id, run_dir);
}
}
// index.html at root — offline static export of the result-page dashboard.
generate_offline_index(
run,
run_dir,
file_stem,
html_path.as_deref(),
pdf_path.as_deref(),
json_path.as_deref(),
scan_config_path.as_deref(),
&result_context,
);
Ok((
RunArtifacts {
output_dir: run_dir.to_path_buf(),
html_path,
pdf_path,
json_path,
csv_path,
xlsx_path,
scan_config_path,
report_title: report_title.to_string(),
result_context,
},
pending_pdf,
))
}
/// Render a static offline result-page dashboard and write it as `index.html` at
/// the root of the run output directory so business users can open it from disk.
#[allow(clippy::too_many_arguments)]
#[allow(clippy::too_many_lines)]
#[allow(clippy::similar_names)]
fn generate_offline_index(
run: &sloc_core::AnalysisRun,
run_dir: &Path,
file_stem: &str,
html_path: Option<&Path>,
pdf_path: Option<&Path>,
json_path: Option<&Path>,
scan_config_path: Option<&Path>,
result_context: &RunResultContext,
) {
let prev_entry = &result_context.prev_entry;
let prev_scan_count = result_context.prev_scan_count;
let project_path = &result_context.project_path;
let scan_delta = prev_entry.as_ref().and_then(|prev| {
prev.json_path
.as_ref()
.and_then(|p| read_json(p).ok())
.map(|prev_run| compute_delta(&prev_run, run))
});
let files_analyzed = run.per_file_records.len() as u64;
let files_skipped = run.skipped_file_records.len() as u64;
let physical_lines = run
.totals_by_language
.iter()
.map(|r| r.total_physical_lines)
.sum::<u64>();
let code_lines = run
.totals_by_language
.iter()
.map(|r| r.code_lines)
.sum::<u64>();
let comment_lines = run
.totals_by_language
.iter()
.map(|r| r.comment_lines)
.sum::<u64>();
let blank_lines = run
.totals_by_language
.iter()
.map(|r| r.blank_lines)
.sum::<u64>();
let mixed_lines = run
.totals_by_language
.iter()
.map(|r| r.mixed_lines_separate)
.sum::<u64>();
let functions = run
.totals_by_language
.iter()
.map(|r| r.functions)
.sum::<u64>();
let classes = run
.totals_by_language
.iter()
.map(|r| r.classes)
.sum::<u64>();
let variables = run
.totals_by_language
.iter()
.map(|r| r.variables)
.sum::<u64>();
let imports = run
.totals_by_language
.iter()
.map(|r| r.imports)
.sum::<u64>();
let prev_sum = prev_entry.as_ref().map(|e| &e.summary);
let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "\u{2014}".into(), |v| v.to_string());
let prev_fa_str = fmt_prev(prev_sum.map(|s| s.files_analyzed));
let prev_fs_str = fmt_prev(prev_sum.map(|s| s.files_skipped));
let prev_pl_str = fmt_prev(prev_sum.map(|s| s.total_physical_lines));
let prev_cl_str = fmt_prev(prev_sum.map(|s| s.code_lines));
let prev_cml_str = fmt_prev(prev_sum.map(|s| s.comment_lines));
let prev_bl_str = fmt_prev(prev_sum.map(|s| s.blank_lines));
let (delta_fa_str, delta_fa_class) =
summary_delta(files_analyzed, prev_sum.map(|s| s.files_analyzed));
let (delta_fs_str, delta_fs_class) =
summary_delta(files_skipped, prev_sum.map(|s| s.files_skipped));
let (delta_pl_str, delta_pl_class) =
summary_delta(physical_lines, prev_sum.map(|s| s.total_physical_lines));
let (delta_cl_str, delta_cl_class) = summary_delta(code_lines, prev_sum.map(|s| s.code_lines));
let (delta_cml_str, delta_cml_class) =
summary_delta(comment_lines, prev_sum.map(|s| s.comment_lines));
let (delta_bl_str, delta_bl_class) =
summary_delta(blank_lines, prev_sum.map(|s| s.blank_lines));
let delta_lines_added: Option<i64> = scan_delta.as_ref().map(sum_added_code_lines);
let delta_lines_removed: Option<i64> = scan_delta.as_ref().map(sum_removed_code_lines);
let (delta_lines_net_str, delta_lines_net_class) =
match (delta_lines_added, delta_lines_removed) {
(Some(a), Some(r)) => {
let net = a - r;
(fmt_delta(net), delta_class(net).to_string())
}
_ => ("\u{2014}".to_string(), "na".to_string()),
};
let git_commit_url = run
.git_remote_url
.as_deref()
.zip(run.git_commit_long.as_deref())
.and_then(|(remote, sha)| remote_to_commit_url(remote, sha));
let git_branch_url = run
.git_remote_url
.as_deref()
.zip(run.git_branch.as_deref())
.and_then(|(remote, branch)| remote_to_branch_url(remote, branch));
let scan_performed_by = run.environment.ci_name.clone().unwrap_or_else(|| {
format!(
"{} / {}",
run.environment.initiator_username, run.environment.initiator_hostname
)
});
// Convert absolute path to relative from run_dir (for file:// navigation).
let make_rel = |p: Option<&Path>| -> Option<String> {
p.and_then(|abs| abs.strip_prefix(run_dir).ok())
.map(|rel| rel.to_string_lossy().replace('\\', "/"))
};
let run_id = &run.tool.run_id;
// Submodule rows with relative paths into submodules/.
let submodule_rows: Vec<SubmoduleRow> = run
.submodule_summaries
.iter()
.map(|s| {
let safe = sanitize_project_label(&s.name);
let key = format!("sub_{safe}");
let sub_path = run_dir.join("submodules").join(format!("{key}.html"));
SubmoduleRow {
name: s.name.clone(),
relative_path: s.relative_path.clone(),
files_analyzed: s.files_analyzed,
code_lines: s.code_lines,
comment_lines: s.comment_lines,
blank_lines: s.blank_lines,
total_physical_lines: s.total_physical_lines,
html_url: if sub_path.exists() {
Some(format!("submodules/{key}.html"))
} else {
None
},
}
})
.collect();
let lang_chart_json = {
let mut langs: Vec<&sloc_core::LanguageSummary> = run.totals_by_language.iter().collect();
langs.sort_by_key(|l| std::cmp::Reverse(l.code_lines));
let entries: Vec<String> = langs
.into_iter()
.take(12)
.map(|l| {
let name = l.language.display_name()
.replace('\\', "\\\\")
.replace('"', "\\\"");
format!(
r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"physical":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
name, l.code_lines, l.comment_lines, l.blank_lines,
l.total_physical_lines, l.functions, l.classes,
l.variables, l.imports, l.files
)
})
.collect();
format!("[{}]", entries.join(","))
};
let scan_config_rel =
make_rel(scan_config_path).unwrap_or_else(|| format!("json/scan-config_{file_stem}.json"));
let template = ResultTemplate {
version: env!("CARGO_PKG_VERSION"),
report_title: run.effective_configuration.reporting.report_title.clone(),
project_path: project_path.clone(),
output_dir: display_path(run_dir),
run_id: run_id.clone(),
run_id_short: run_id
.split('-')
.next_back()
.unwrap_or(run_id)
.chars()
.take(7)
.collect(),
files_analyzed,
files_skipped,
physical_lines,
code_lines,
comment_lines,
blank_lines,
mixed_lines,
functions,
classes,
variables,
imports,
html_url: make_rel(html_path),
pdf_url: make_rel(pdf_path),
json_url: make_rel(json_path),
html_download_url: make_rel(html_path),
pdf_download_url: make_rel(pdf_path),
json_download_url: make_rel(json_path),
html_path: html_path.map(display_path),
json_path: json_path.map(display_path),
prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
prev_fa_str,
prev_fs_str,
prev_pl_str,
prev_cl_str,
prev_cml_str,
prev_bl_str,
delta_fa_str,
delta_fa_class: delta_fa_class.to_string(),
delta_fs_str,
delta_fs_class: delta_fs_class.to_string(),
delta_pl_str,
delta_pl_class: delta_pl_class.to_string(),
delta_cl_str,
delta_cl_class: delta_cl_class.to_string(),
delta_cml_str,
delta_cml_class: delta_cml_class.to_string(),
delta_bl_str,
delta_bl_class: delta_bl_class.to_string(),
delta_lines_added,
delta_lines_removed,
delta_lines_net_str,
delta_lines_net_class,
delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
delta_unmodified_lines: scan_delta.as_ref().map(|d| {
d.file_deltas
.iter()
.filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
.map(|f| {
#[allow(clippy::cast_sign_loss)]
let n = f.current_code as u64;
n
})
.sum()
}),
git_branch: run.git_branch.clone(),
git_branch_url,
git_commit: run.git_commit_short.clone(),
git_commit_long: run.git_commit_long.clone(),
git_author: run.git_commit_author.clone(),
git_commit_url,
scan_performed_by,
scan_time_display: fmt_la_time_meta(run.tool.timestamp_utc),
os_display: format!(
"{} / {}",
run.environment.operating_system, run.environment.architecture
),
test_count: run.summary_totals.test_count,
current_scan_number: prev_scan_count + 1,
prev_scan_count,
submodule_rows,
pdf_generating: false,
scan_config_url: scan_config_rel,
lang_chart_json,
scatter_chart_json: String::new(),
semantic_chart_json: String::new(),
submodule_chart_json: String::new(),
has_submodule_data: !run.submodule_summaries.is_empty(),
has_semantic_data: run
.totals_by_language
.iter()
.any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
csp_nonce: String::new(),
confluence_configured: false,
server_mode: false,
report_header_footer: run
.effective_configuration
.reporting
.report_header_footer
.clone(),
is_offline: true,
cyclomatic_complexity: run.summary_totals.cyclomatic_complexity,
lsloc: run.summary_totals.lsloc,
uloc: run.uloc,
dryness_pct_str: run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}")),
duplicate_group_count: run.duplicate_groups.len(),
has_cocomo: run.cocomo.is_some(),
cocomo_effort_str: run
.cocomo
.as_ref()
.map_or(String::new(), |c| format!("{:.2}", c.effort_person_months)),
cocomo_duration_str: run
.cocomo
.as_ref()
.map_or(String::new(), |c| format!("{:.2}", c.duration_months)),
cocomo_staff_str: run
.cocomo
.as_ref()
.map_or(String::new(), |c| format!("{:.2}", c.avg_staff)),
cocomo_ksloc_str: run
.cocomo
.as_ref()
.map_or(String::new(), |c| format!("{:.2}", c.ksloc)),
cocomo_mode_label: run.cocomo.as_ref().map_or_else(
|| "Organic".to_string(),
|c| {
use sloc_core::CocomoMode;
match c.mode {
CocomoMode::Organic => "Organic",
CocomoMode::SemiDetached => "Semi-detached",
CocomoMode::Embedded => "Embedded",
}
.to_string()
},
),
cocomo_mode_tooltip: run.cocomo.as_ref().map_or(String::new(), |c| {
use sloc_core::CocomoMode;
match c.mode {
CocomoMode::Organic => {
"Organic: A small team working on a well-understood \
project in a familiar environment with minimal external constraints. \
Suited for internal tools, utilities, and projects with stable requirements. \
Effort = 2.4 \u{00D7} KSLOC^1.05."
}
CocomoMode::SemiDetached => {
"Semi-detached: A mixed team with varying experience \
tackling a project with moderate novelty and some rigid constraints. \
Typical for compilers, transaction systems, and batch processors. \
Effort = 3.0 \u{00D7} KSLOC^1.12."
}
CocomoMode::Embedded => {
"Embedded: Tight hardware, software, or operational \
constraints requiring significant innovation and deep integration work. \
Typical for real-time control systems and safety-critical software. \
Effort = 3.6 \u{00D7} KSLOC^1.20."
}
}
.to_string()
}),
complexity_alert: 0,
};
if let Ok(html) = template.render() {
let index_path = run_dir.join("index.html");
if let Err(e) = fs::write(&index_path, html) {
eprintln!("[oxide-sloc] index.html write failed (non-fatal): {e:#}");
}
}
}
/// Find a scan-config JSON file in `dir`, checking json/ subfolder first (new layout),
/// then root (old flat layout), for backwards compatibility.
fn find_scan_config_in_dir(dir: &Path) -> Option<PathBuf> {
// New layout: json/scan-config_*.json
if let Some(found) = find_scan_config_in_dir_flat(&dir.join("json")) {
return Some(found);
}
// Old flat layout: scan-config.json or scan-config_*.json at root
find_scan_config_in_dir_flat(dir)
}
fn find_scan_config_in_dir_flat(dir: &Path) -> Option<PathBuf> {
let exact = dir.join("scan-config.json");
if exact.exists() {
return Some(exact);
}
fs::read_dir(dir).ok().and_then(|entries| {
entries
.filter_map(std::result::Result::ok)
.find(|e| {
let name = e.file_name();
let name = name.to_string_lossy();
name.starts_with("scan-config") && name.ends_with(".json")
})
.map(|e| e.path())
})
}
// ── Config export / import ────────────────────────────────────────────────────
/// POST /export/pdf — JSON body `{ "html": "...", "filename": "report.pdf" }`
/// Renders the HTML to PDF via headless Chrome and returns the PDF bytes.
#[derive(Deserialize)]
struct ExportPdfRequest {
html: String,
#[serde(default)]
filename: Option<String>,
}
async fn export_pdf_handler(Json(body): Json<ExportPdfRequest>) -> impl IntoResponse {
let html_content = body.html;
let filename = body.filename.unwrap_or_else(|| "report.pdf".to_string());
if html_content.is_empty() {
return (StatusCode::BAD_REQUEST, "Missing html field").into_response();
}
// Write HTML to a temp file, run headless Chrome PDF export, read result.
let tmp_dir = std::env::temp_dir();
let html_path = tmp_dir.join(format!(
"sloc-export-{}.html",
uuid::Uuid::new_v4().simple()
));
let pdf_path = tmp_dir.join(format!("sloc-export-{}.pdf", uuid::Uuid::new_v4().simple()));
if let Err(e) = std::fs::write(&html_path, &html_content) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to write temp HTML: {e}"),
)
.into_response();
}
let pdf_result = write_pdf_from_html(&html_path, &pdf_path);
let _ = std::fs::remove_file(&html_path);
if let Err(e) = pdf_result {
let _ = std::fs::remove_file(&pdf_path);
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("PDF generation failed: {e}"),
)
.into_response();
}
let pdf_bytes = match std::fs::read(&pdf_path) {
Ok(b) => b,
Err(e) => {
let _ = std::fs::remove_file(&pdf_path);
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to read PDF: {e}"),
)
.into_response();
}
};
let _ = std::fs::remove_file(&pdf_path);
let safe_name: String = filename
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
c
} else {
'_'
}
})
.collect();
let disposition = format!("attachment; filename=\"{safe_name}\"");
(
[
(header::CONTENT_TYPE, "application/pdf".to_string()),
(header::CONTENT_DISPOSITION, disposition),
],
pdf_bytes,
)
.into_response()
}
async fn export_config_handler(State(state): State<AppState>) -> impl IntoResponse {
let toml_str = match toml::to_string_pretty(&state.base_config) {
Ok(s) => s,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("serialization error: {e}"),
)
.into_response();
}
};
(
[
(header::CONTENT_TYPE, "application/toml; charset=utf-8"),
(
header::CONTENT_DISPOSITION,
"attachment; filename=\".oxide-sloc.toml\"",
),
],
toml_str,
)
.into_response()
}
#[derive(Serialize)]
struct OkResponse {
ok: bool,
}
#[derive(Serialize)]
struct SaveProfileResponse {
ok: bool,
id: String,
}
#[derive(Serialize)]
struct ProfileListResponse {
profiles: Vec<ScanProfile>,
}
#[derive(Serialize)]
struct ImportConfigResponse {
ok: bool,
config: sloc_config::AppConfig,
}
#[derive(Deserialize)]
struct ImportConfigBody {
toml: String,
}
async fn import_config_handler(Json(body): Json<ImportConfigBody>) -> impl IntoResponse {
match toml::from_str::<sloc_config::AppConfig>(&body.toml) {
Ok(config) => {
if let Err(e) = config.validate() {
return error::unprocessable_entity(&e.to_string());
}
Json(ImportConfigResponse { ok: true, config }).into_response()
}
Err(e) => error::bad_request(&format!("TOML parse error: {e}")),
}
}
// ── Scan profiles API ─────────────────────────────────────────────────────────
async fn api_list_scan_profiles(State(state): State<AppState>) -> impl IntoResponse {
let store = state.scan_profiles.lock().await;
Json(ProfileListResponse {
profiles: store.profiles.clone(),
})
}
#[derive(Deserialize)]
struct SaveScanProfileBody {
name: String,
params: serde_json::Value,
}
async fn api_save_scan_profile(
State(state): State<AppState>,
Json(body): Json<SaveScanProfileBody>,
) -> impl IntoResponse {
if body.name.trim().is_empty() {
return error::bad_request("name must not be empty");
}
let id = uuid::Uuid::new_v4().to_string();
let profile = ScanProfile {
id: id.clone(),
name: body.name.trim().to_string(),
created_at: chrono::Utc::now().to_rfc3339(),
params: body.params,
};
let mut store = state.scan_profiles.lock().await;
store.profiles.push(profile);
if let Err(e) = store.save(&state.scan_profiles_path) {
tracing::warn!("failed to persist scan profiles: {e}");
}
drop(store);
(
StatusCode::CREATED,
Json(SaveProfileResponse { ok: true, id }),
)
.into_response()
}
async fn api_delete_scan_profile(
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
) -> impl IntoResponse {
let mut store = state.scan_profiles.lock().await;
let before = store.profiles.len();
store.profiles.retain(|p| p.id != id);
if store.profiles.len() == before {
drop(store);
return error::not_found("profile not found");
}
if let Err(e) = store.save(&state.scan_profiles_path) {
tracing::warn!("failed to persist scan profiles: {e}");
}
drop(store);
Json(OkResponse { ok: true }).into_response()
}
fn resolve_output_root(raw: Option<&str>) -> PathBuf {
let value = raw.unwrap_or("out/web").trim();
let path = if value.is_empty() {
PathBuf::from("out/web")
} else {
PathBuf::from(value)
};
if path.is_absolute() {
path
} else {
workspace_root().join(path)
}
}
/// Derive the directory that holds remote-repo clones from the output root.
fn resolve_git_clones_dir(output_root: &Path) -> PathBuf {
std::env::var("SLOC_GIT_CLONES_DIR")
.map_or_else(|_| output_root.join("git-clones"), PathBuf::from)
}
/// Build a deterministic filesystem path for a cloned remote repository.
/// Keeps only filename-safe characters and caps at 80 chars to avoid path-length issues.
pub(crate) fn git_clone_dest(repo_url: &str, clones_dir: &Path) -> PathBuf {
let safe: String = repo_url
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
c
} else {
'_'
}
})
.take(80)
.collect();
clones_dir.join(safe)
}
/// Run a scan on `scan_path`, persist HTML + JSON artifacts, and return the run ID.
/// Runs synchronously — call from `tokio::task::spawn_blocking`.
pub(crate) fn scan_path_to_artifacts(
scan_path: &Path,
base_config: &AppConfig,
label: &str,
) -> Result<(String, RunArtifacts, sloc_core::AnalysisRun)> {
let mut config = base_config.clone();
config.discovery.root_paths = vec![scan_path.to_path_buf()];
label.clone_into(&mut config.reporting.report_title);
let run = analyze(&config, "git", None, None)?;
let html = render_html(&run)?;
let run_id = run.tool.run_id.clone();
let project_label = sanitize_project_label(label);
let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
let file_stem = {
let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
if commit.is_empty() {
project_label
} else {
format!("{project_label}_{commit}")
}
};
let (artifacts, _pending_pdf) = persist_run_artifacts(
&run,
&html,
&output_dir,
label,
&file_stem,
RunResultContext::default(),
)?;
Ok((run_id, artifacts, run))
}
/// Re-spawn background poll tasks for any polling schedules saved to disk.
async fn restart_poll_schedules(state: &AppState) {
let store = state.schedules.lock().await;
let poll_schedules: Vec<_> = store
.schedules
.iter()
.filter(|s| s.kind == sloc_git::ScanScheduleKind::Poll && s.enabled)
.cloned()
.collect();
drop(store);
for schedule in poll_schedules {
let interval = schedule.interval_secs.unwrap_or(300);
let st = state.clone();
tokio::spawn(async move { git_webhook::poll_loop(st, schedule, interval).await });
}
}
fn split_patterns(raw: Option<&str>) -> Vec<String> {
raw.unwrap_or("")
.lines()
.flat_map(|line| line.split(','))
.map(str::trim)
.filter(|part| !part.is_empty())
.map(ToOwned::to_owned)
.collect()
}
#[must_use]
pub fn build_sub_run(
parent: &AnalysisRun,
sub: &sloc_core::SubmoduleSummary,
parent_path: &str,
) -> AnalysisRun {
let sub_files: Vec<_> = parent
.per_file_records
.iter()
.filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
.cloned()
.collect();
let mut config = parent.effective_configuration.clone();
config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
// Aggregate semantic metrics that SubmoduleSummary doesn't store.
let mut functions = 0u64;
let mut classes = 0u64;
let mut variables = 0u64;
let mut imports = 0u64;
let mut test_count = 0u64;
let mut test_assertion_count = 0u64;
let mut test_suite_count = 0u64;
let mut mixed_lines_separate = 0u64;
let mut coverage_lines_found = 0u64;
let mut coverage_lines_hit = 0u64;
let mut coverage_functions_found = 0u64;
let mut coverage_functions_hit = 0u64;
let mut coverage_branches_found = 0u64;
let mut coverage_branches_hit = 0u64;
for r in &sub_files {
functions += r.raw_line_categories.functions;
classes += r.raw_line_categories.classes;
variables += r.raw_line_categories.variables;
imports += r.raw_line_categories.imports;
test_count += r.raw_line_categories.test_count;
test_assertion_count += r.raw_line_categories.test_assertion_count;
test_suite_count += r.raw_line_categories.test_suite_count;
mixed_lines_separate += r.effective_counts.mixed_lines_separate;
if let Some(cov) = &r.coverage {
coverage_lines_found += u64::from(cov.lines_found);
coverage_lines_hit += u64::from(cov.lines_hit);
coverage_functions_found += u64::from(cov.functions_found);
coverage_functions_hit += u64::from(cov.functions_hit);
coverage_branches_found += u64::from(cov.branches_found);
coverage_branches_hit += u64::from(cov.branches_hit);
}
}
AnalysisRun {
tool: parent.tool.clone(),
environment: parent.environment.clone(),
effective_configuration: config,
input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
summary_totals: SummaryTotals {
files_considered: sub.files_analyzed,
files_analyzed: sub.files_analyzed,
files_skipped: 0,
total_physical_lines: sub.total_physical_lines,
code_lines: sub.code_lines,
comment_lines: sub.comment_lines,
blank_lines: sub.blank_lines,
mixed_lines_separate,
functions,
classes,
variables,
imports,
test_count,
test_assertion_count,
test_suite_count,
coverage_lines_found,
coverage_lines_hit,
coverage_functions_found,
coverage_functions_hit,
coverage_branches_found,
coverage_branches_hit,
cyclomatic_complexity: 0,
lsloc: None,
},
totals_by_language: sub.language_summaries.clone(),
per_file_records: sub_files,
skipped_file_records: vec![],
warnings: vec![],
submodule_summaries: vec![],
git_commit_short: sub.git_commit_short.clone(),
git_commit_long: sub.git_commit_long.clone(),
git_branch: sub.git_branch.clone(),
git_commit_author: sub.git_commit_author.clone(),
git_commit_date: sub.git_commit_date.clone(),
git_tags: None,
git_nearest_tag: None,
git_remote_url: sub.git_remote_url.clone(),
style_summary: None,
cocomo: None,
uloc: 0,
dryness_pct: None,
duplicate_groups: vec![],
duplicates_excluded: 0,
}
}
#[must_use]
pub fn sanitize_project_label(raw: &str) -> String {
// Split on both '/' and '\' so Windows paths work correctly on Linux CI runners,
// where `Path` treats '\' as a literal character, not a separator.
let candidate = raw
.split(['/', '\\'])
.rfind(|s| !s.is_empty())
.unwrap_or("project");
let mut value = String::with_capacity(candidate.len());
for ch in candidate.chars() {
if ch.is_ascii_alphanumeric() {
value.push(ch.to_ascii_lowercase());
} else {
value.push('-');
}
}
let compact = value.trim_matches('-').to_string();
if compact.is_empty() {
"project".to_string()
} else {
compact
}
}
/// Strip the Windows extended-length prefix (`\\?\`) from a canonicalized path so that
/// comparisons with non-canonicalized stored paths work correctly.
fn strip_unc_prefix(path: PathBuf) -> PathBuf {
let s = path.to_string_lossy();
if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
return PathBuf::from(format!(r"\\{rest}"));
}
if let Some(rest) = s.strip_prefix(r"\\?\") {
return PathBuf::from(rest);
}
path
}
/// Convert a git remote URL (https or git@) + commit SHA into a browser-openable
/// commit page URL for the most common hosting platforms.
fn remote_to_commit_url(remote: &str, sha: &str) -> Option<String> {
let base = if let Some(rest) = remote.strip_prefix("git@") {
let (host, path) = rest.split_once(':')?;
format!("https://{}/{}", host, path.trim_end_matches(".git"))
} else if remote.starts_with("https://") || remote.starts_with("http://") {
remote
.trim_end_matches('/')
.trim_end_matches(".git")
.to_owned()
} else {
return None;
};
let base = base.trim_end_matches('/');
// GitLab uses /-/commit/; everything else uses /commit/
if base.contains("gitlab.com") || base.contains("gitlab.") {
Some(format!("{base}/-/commit/{sha}"))
} else if base.contains("bitbucket.org") {
Some(format!("{base}/commits/{sha}"))
} else {
Some(format!("{base}/commit/{sha}"))
}
}
/// Convert a git remote URL (https or git@) + branch name into a browser-openable
/// branch page URL for the most common hosting platforms.
fn remote_to_branch_url(remote: &str, branch: &str) -> Option<String> {
let base = if let Some(rest) = remote.strip_prefix("git@") {
let (host, path) = rest.split_once(':')?;
format!("https://{}/{}", host, path.trim_end_matches(".git"))
} else if remote.starts_with("https://") || remote.starts_with("http://") {
remote
.trim_end_matches('/')
.trim_end_matches(".git")
.to_owned()
} else {
return None;
};
let base = base.trim_end_matches('/');
if base.contains("gitlab.com") || base.contains("gitlab.") {
Some(format!("{base}/-/tree/{branch}"))
} else {
Some(format!("{base}/tree/{branch}"))
}
}
fn display_path(path: &Path) -> String {
let s = path.to_string_lossy();
// Strip Windows extended-length prefix for display only; the underlying
// PathBuf remains unchanged so file operations are unaffected.
// \\?\UNC\server\share → \\server\share (file share / SMB)
// \\?\C:\path → C:\path (local drive)
if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
return format!(r"\\{rest}");
}
if let Some(rest) = s.strip_prefix(r"\\?\") {
return rest.to_owned();
}
s.into_owned()
}
fn sanitize_path_str(s: &str) -> String {
// Forward-slash variants of the Windows extended-length prefix that appear
// when paths stored as plain strings have been processed through some path
// normalisation (e.g. //?/C:/... instead of \\?\C:\...).
if let Some(rest) = s.strip_prefix("//?/UNC/") {
return format!("//{rest}");
}
if let Some(rest) = s.strip_prefix("//?/") {
return rest.to_owned();
}
display_path(Path::new(s))
}
fn workspace_root() -> PathBuf {
// OXIDE_SLOC_ROOT env var takes priority — useful in Docker, systemd, CI.
if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
let p = PathBuf::from(root);
if p.is_dir() {
return p;
}
}
// Current working directory — works for `cargo run` from the project root
// and for scripts/run.sh which cds there first.
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
/// Produce a filesystem-safe label for a git-sourced scan: `<repo>_at_<ref>_sloc`.
fn make_git_label(repo: &str, ref_name: &str) -> String {
if repo.is_empty() || ref_name.is_empty() {
return String::new();
}
let base = repo
.trim_end_matches('/')
.trim_end_matches(".git")
.rsplit('/')
.next()
.unwrap_or("repo");
let ref_safe: String = ref_name
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '.' {
c
} else {
'_'
}
})
.collect();
format!("{base}_at_{ref_safe}_sloc")
}
/// Return the user's Desktop directory, falling back to `out/web` in the workspace.
fn desktop_dir() -> PathBuf {
if let Ok(profile) = std::env::var("USERPROFILE") {
let p = PathBuf::from(profile).join("Desktop");
if p.exists() {
return p;
}
}
if let Ok(home) = std::env::var("HOME") {
let p = PathBuf::from(home).join("Desktop");
if p.exists() {
return p;
}
}
workspace_root().join("out").join("web")
}
fn resolve_input_path(raw: &str) -> PathBuf {
let trimmed = raw.trim();
if trimmed.is_empty() {
return workspace_root().join("samples").join("basic");
}
let candidate = PathBuf::from(trimmed);
let resolved = if candidate.is_absolute() {
candidate
} else {
let rooted = workspace_root().join(&candidate);
if rooted.exists() {
rooted
} else {
workspace_root().join(candidate)
}
};
// fs::canonicalize on Windows returns \\?\-prefixed extended-length paths;
// strip that prefix so stored paths and the displayed "Project path" are clean.
let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
PathBuf::from(display_path(&canonical))
}
fn dir_size_bytes(path: &Path) -> u64 {
let mut total = 0u64;
if let Ok(rd) = fs::read_dir(path) {
for entry in rd.filter_map(Result::ok) {
let p = entry.path();
if p.is_file() {
if let Ok(meta) = p.metadata() {
total += meta.len();
}
} else if p.is_dir() {
total += dir_size_bytes(&p);
}
}
}
total
}
#[allow(clippy::cast_precision_loss)] // byte-count display formatting, precision loss acceptable
fn format_dir_size(bytes: u64) -> String {
if bytes >= 1_073_741_824 {
format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
} else if bytes >= 1_048_576 {
format!("{:.1} MB", bytes as f64 / 1_048_576.0)
} else if bytes >= 1_024 {
format!("{:.0} KB", bytes as f64 / 1_024.0)
} else {
format!("{bytes} B")
}
}
fn render_submodule_chips(
root: &Path,
submodules: &[(String, std::path::PathBuf)],
out: &mut String,
) {
use std::fmt::Write as _;
let count = submodules.len();
out.push_str(r#"<div class="submodule-preview-strip">"#);
write!(
out,
r#"<div class="submodule-preview-label"><svg viewBox="0 0 24 24" aria-hidden="true"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/><circle cx="6" cy="6" r="3"/></svg><strong>{count}</strong> git submodule{} detected</div>"#,
if count == 1 { "" } else { "s" }
)
.ok();
out.push_str(r#"<div class="submodule-preview-chips">"#);
for (sub_name, sub_rel_path) in submodules {
let sub_abs = root.join(sub_rel_path);
let sub_size = format_dir_size(dir_size_bytes(&sub_abs));
let mut sub_stats = PreviewStats::default();
let mut sub_rows: Vec<PreviewRow> = Vec::new();
let mut sub_langs: Vec<&'static str> = Vec::new();
let mut sub_budget = PreviewBudget {
shown: 0,
max_entries: 2000,
max_depth: 9,
};
let mut sub_next_id = 1usize;
let _ = collect_preview_rows(
&sub_abs,
&sub_abs,
0,
None,
&mut sub_next_id,
&mut sub_budget,
&mut sub_stats,
&mut sub_rows,
&mut sub_langs,
&[],
&[],
);
let stats_json = format!(
r#"{{"dirs":{},"files":{},"supported":{},"skipped":{},"unsupported":{}}}"#,
sub_stats.directories,
sub_stats.files,
sub_stats.supported,
sub_stats.skipped,
sub_stats.unsupported
);
write!(
out,
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>"#,
escape_html(sub_name),
escape_html(&sub_rel_path.to_string_lossy()),
escape_html(&sub_size),
escape_html(&stats_json),
escape_html(sub_name),
escape_html(&sub_size),
)
.ok();
}
out.push_str(
r#"</div><button type="button" class="submodule-base-repo-btn" style="display:none">↑ Base repo</button>"#,
);
out.push_str(r"</div>");
}
fn render_language_pills_row(languages: &[&str], out: &mut String) {
use std::fmt::Write as _;
if languages.is_empty() {
out.push_str(
r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
);
return;
}
out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
for language in languages {
if let Some(icon) = language_icon_file(language) {
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();
} else if let Some(svg) = language_inline_svg(language) {
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();
} else {
write!(
out,
r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
escape_html(&language.to_ascii_lowercase()),
escape_html(language)
)
.ok();
}
}
}
#[allow(clippy::too_many_lines)]
fn build_preview_html(
root: &Path,
include_patterns: &[String],
exclude_patterns: &[String],
) -> Result<String> {
if !root.exists() {
return Ok(format!(
r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
escape_html(&display_path(root))
));
}
let _selected = display_path(root);
let mut stats = PreviewStats::default();
let mut rows = Vec::new();
let mut languages = Vec::new();
let mut budget = PreviewBudget {
shown: 0,
max_entries: 600,
max_depth: 9,
};
let mut next_row_id = 1usize;
let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
|| root.to_string_lossy().into_owned(),
std::string::ToString::to_string,
);
let root_modified = root
.metadata()
.ok()
.and_then(|meta| meta.modified().ok())
.map_or_else(|| "-".to_string(), format_system_time);
rows.push(PreviewRow {
row_id: 0,
parent_row_id: None,
depth: 0,
name: format!("{root_name}/"),
kind: PreviewKind::Dir,
is_dir: true,
language: None,
modified: root_modified,
type_label: "Directory".to_string(),
});
collect_preview_rows(
root,
root,
0,
Some(0),
&mut next_row_id,
&mut budget,
&mut stats,
&mut rows,
&mut languages,
include_patterns,
exclude_patterns,
)?;
let root_size = format_dir_size(dir_size_bytes(root));
let mut out = String::new();
write!(
out,
r#"<div class="explorer-wrap" data-project-size="{}">"#,
escape_html(&root_size)
)
.ok();
out.push_str(r#"<div class="explorer-toolbar compact">"#);
out.push_str(r#"<div class="explorer-title-group">"#);
out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
out.push_str(r"</div></div>");
out.push_str(r#"<div class="scope-stats">"#);
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();
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();
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();
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();
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();
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>"#);
out.push_str(r"</div>");
let submodules = sloc_core::detect_submodules(root);
if !submodules.is_empty() {
render_submodule_chips(root, &submodules, &mut out);
}
out.push_str(r#"<div class="scope-info-row">"#);
out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
render_language_pills_row(&languages, &mut out);
out.push_str(r"</div></div>");
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>"#);
out.push_str(r"</div>");
out.push_str(r#"<div class="file-explorer-shell">"#);
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>"#);
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>"#);
out.push_str(r#"<div class="file-explorer-tree">"#);
for row in rows {
let status_label = row.kind.label();
let lang_attr = row.language.unwrap_or("");
let toggle_html = if row.is_dir {
r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">▾</button>"#
.to_string()
} else {
r#"<span class="tree-bullet">•</span>"#.to_string()
};
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();
}
if budget.shown >= budget.max_entries {
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>"#);
}
out.push_str(r"</div></div></div>");
Ok(out)
}
#[derive(Default)]
struct PreviewStats {
directories: usize,
files: usize,
supported: usize,
skipped: usize,
unsupported: usize,
}
struct PreviewRow {
row_id: usize,
parent_row_id: Option<usize>,
depth: usize,
name: String,
kind: PreviewKind,
is_dir: bool,
language: Option<&'static str>,
modified: String,
type_label: String,
}
#[derive(Copy, Clone)]
enum PreviewKind {
Dir,
Supported,
Skipped,
Unsupported,
}
impl PreviewKind {
const fn filter_key(self) -> &'static str {
match self {
Self::Dir => "dir",
Self::Supported => "supported",
Self::Skipped => "skipped",
Self::Unsupported => "unsupported",
}
}
const fn label(self) -> &'static str {
match self {
Self::Dir => "dir",
Self::Supported => "supported",
Self::Skipped => "skipped by policy",
Self::Unsupported => "unsupported",
}
}
const fn badge_class(self) -> &'static str {
match self {
Self::Dir => "badge badge-dir",
Self::Supported => "badge badge-scan",
Self::Skipped => "badge badge-skip",
Self::Unsupported => "badge badge-unsupported",
}
}
const fn node_class(self) -> &'static str {
match self {
Self::Dir => "tree-node-dir",
Self::Supported => "tree-node-supported",
Self::Skipped => "tree-node-skipped",
Self::Unsupported => "tree-node-unsupported",
}
}
}
struct PreviewBudget {
shown: usize,
max_entries: usize,
max_depth: usize,
}
/// Handle a single directory entry inside `collect_preview_rows`.
/// Returns `true` when the entry was handled (caller should `continue`).
#[allow(clippy::too_many_arguments)]
fn handle_preview_dir_entry(
root: &Path,
path: &Path,
name: &str,
modified: String,
depth: usize,
parent_row_id: Option<usize>,
row_id: usize,
next_row_id: &mut usize,
budget: &mut PreviewBudget,
stats: &mut PreviewStats,
rows: &mut Vec<PreviewRow>,
languages: &mut Vec<&'static str>,
include_patterns: &[String],
exclude_patterns: &[String],
) -> Result<()> {
let relative = preview_relative_path(root, path);
if should_skip_preview_directory(&relative, exclude_patterns) {
return Ok(());
}
stats.directories += 1;
rows.push(PreviewRow {
row_id,
parent_row_id,
depth: depth + 1,
name: format!("{name}/"),
kind: PreviewKind::Dir,
is_dir: true,
language: None,
modified,
type_label: "Directory".to_string(),
});
budget.shown += 1;
if !matches!(name, ".git" | "node_modules" | "target") {
collect_preview_rows(
root,
path,
depth + 1,
Some(row_id),
next_row_id,
budget,
stats,
rows,
languages,
include_patterns,
exclude_patterns,
)?;
}
Ok(())
}
/// Handle a single file entry inside `collect_preview_rows`.
#[allow(clippy::too_many_arguments)]
fn handle_preview_file_entry(
root: &Path,
path: &Path,
name: &str,
modified: String,
depth: usize,
parent_row_id: Option<usize>,
row_id: usize,
budget: &mut PreviewBudget,
stats: &mut PreviewStats,
rows: &mut Vec<PreviewRow>,
languages: &mut Vec<&'static str>,
include_patterns: &[String],
exclude_patterns: &[String],
) {
let relative = preview_relative_path(root, path);
if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
return;
}
stats.files += 1;
let kind = classify_preview_file(name);
match kind {
PreviewKind::Supported => stats.supported += 1,
PreviewKind::Skipped => stats.skipped += 1,
PreviewKind::Unsupported => stats.unsupported += 1,
PreviewKind::Dir => {}
}
let language = detect_language_name(name);
if let Some(lang) = language {
if !languages.contains(&lang) {
languages.push(lang);
}
}
rows.push(PreviewRow {
row_id,
parent_row_id,
depth: depth + 1,
name: name.to_owned(),
kind,
is_dir: false,
language,
modified,
type_label: preview_type_label(name, language, kind),
});
budget.shown += 1;
}
#[allow(clippy::too_many_arguments)]
#[allow(clippy::too_many_lines)]
fn collect_preview_rows(
root: &Path,
dir: &Path,
depth: usize,
parent_row_id: Option<usize>,
next_row_id: &mut usize,
budget: &mut PreviewBudget,
stats: &mut PreviewStats,
rows: &mut Vec<PreviewRow>,
languages: &mut Vec<&'static str>,
include_patterns: &[String],
exclude_patterns: &[String],
) -> Result<()> {
if depth >= budget.max_depth || budget.shown >= budget.max_entries {
return Ok(());
}
let mut entries = fs::read_dir(dir)
.with_context(|| format!("failed to read directory {}", dir.display()))?
.filter_map(std::result::Result::ok)
.collect::<Vec<_>>();
entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
for entry in entries {
if budget.shown >= budget.max_entries {
break;
}
let path = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
let Ok(metadata) = entry.metadata() else {
continue;
};
let row_id = *next_row_id;
*next_row_id += 1;
let modified = metadata
.modified()
.ok()
.map_or_else(|| "-".to_string(), format_system_time);
if metadata.is_dir() {
handle_preview_dir_entry(
root,
&path,
&name,
modified,
depth,
parent_row_id,
row_id,
next_row_id,
budget,
stats,
rows,
languages,
include_patterns,
exclude_patterns,
)?;
continue;
}
if metadata.is_file() {
handle_preview_file_entry(
root,
&path,
&name,
modified,
depth,
parent_row_id,
row_id,
budget,
stats,
rows,
languages,
include_patterns,
exclude_patterns,
);
}
}
Ok(())
}
fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
if let Some(language) = language {
return format!("{language} source");
}
let lower = name.to_ascii_lowercase();
let ext = Path::new(&lower)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
match kind {
PreviewKind::Skipped => {
if lower.ends_with(".min.js") {
"Minified asset".to_string()
} else if [
"png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
]
.contains(&ext)
{
"Binary or archive".to_string()
} else {
"Skipped file".to_string()
}
}
PreviewKind::Unsupported => {
if ext.is_empty() {
"Unsupported file".to_string()
} else {
format!("{} file", ext.to_ascii_uppercase())
}
}
PreviewKind::Supported => "Supported source".to_string(),
PreviewKind::Dir => "Directory".to_string(),
}
}
fn format_system_time(time: SystemTime) -> String {
#[allow(clippy::cast_possible_wrap)]
let secs = match time.duration_since(UNIX_EPOCH) {
Ok(duration) => duration.as_secs() as i64,
Err(_) => return "-".to_string(),
};
let days = secs.div_euclid(86_400);
let secs_of_day = secs.rem_euclid(86_400);
let (year, month, day) = civil_from_days(days);
let hour = secs_of_day / 3_600;
let minute = (secs_of_day % 3_600) / 60;
format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
fn civil_from_days(days: i64) -> (i32, u32, u32) {
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = mp + if mp < 10 { 3 } else { -9 };
let year = y + i64::from(m <= 2);
(year as i32, m as u32, d as u32)
}
// The input is already lowercased via `to_ascii_lowercase()` before calling
// `ends_with`, so the comparisons are inherently case-insensitive.
#[allow(clippy::case_sensitive_file_extension_comparisons)]
fn detect_language_name(name: &str) -> Option<&'static str> {
let lower = name.to_ascii_lowercase();
if lower.ends_with(".c") || lower.ends_with(".h") {
Some("C")
} else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
.iter()
.any(|s| lower.ends_with(s))
{
Some("C++")
} else if lower.ends_with(".cs") {
Some("C#")
} else if lower.ends_with(".py") {
Some("Python")
} else if lower.ends_with(".sh") {
Some("Shell")
} else if [".ps1", ".psm1", ".psd1"]
.iter()
.any(|s| lower.ends_with(s))
{
Some("PowerShell")
} else {
None
}
}
fn language_icon_file(language: &str) -> Option<&'static str> {
match language {
"C" => Some("c.png"),
"C++" => Some("cpp.png"),
"C#" => Some("c-sharp.png"),
"Python" => Some("python.png"),
"Shell" => Some("shell.png"),
"PowerShell" => Some("powershell.png"),
"JavaScript" => Some("java-script.png"),
"HTML" => Some("html-5.png"),
"Java" => Some("java.png"),
"Visual Basic" => Some("visual-basic.png"),
"Assembly" => Some("asm.png"),
"Go" => Some("go.png"),
"R" => Some("r.png"),
"XML" => Some("xml.png"),
"Groovy" => Some("groovy.png"),
"Dockerfile" => Some("docker.png"),
"Makefile" => Some("makefile.svg"),
"Perl" => Some("perl.svg"),
_ => None,
}
}
// Inline SVG badges for languages that have no PNG icon in images/icons/.
// Using inline SVG keeps the web UI fully self-contained — no extra files
// needed on disk, no 404s on air-gapped deployments.
// r##"..."## delimiter used because the SVG content contains "#" (hex colours).
fn language_inline_svg(language: &str) -> Option<&'static str> {
match language {
"Rust" => Some(
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>"##,
),
"TypeScript" => Some(
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>"##,
),
_ => None,
}
}
// The input is already lowercased via `to_ascii_lowercase()` before the
// `ends_with` calls, so these comparisons are inherently case-insensitive.
#[allow(clippy::case_sensitive_file_extension_comparisons)]
fn classify_preview_file(name: &str) -> PreviewKind {
let lower = name.to_ascii_lowercase();
let scannable = [
".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
".psm1", ".psd1",
]
.iter()
.any(|suffix| lower.ends_with(suffix));
if scannable {
PreviewKind::Supported
} else if lower.ends_with(".min.js")
|| lower.ends_with(".lock")
|| lower.ends_with(".png")
|| lower.ends_with(".jpg")
|| lower.ends_with(".jpeg")
|| lower.ends_with(".gif")
|| lower.ends_with(".zip")
|| lower.ends_with(".pdf")
|| lower.ends_with(".pyc")
|| lower.ends_with(".xz")
|| lower.ends_with(".tar")
|| lower.ends_with(".gz")
{
PreviewKind::Skipped
} else {
PreviewKind::Unsupported
}
}
fn preview_relative_path(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.ok()
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
.trim_matches('/')
.to_string()
}
fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
if relative.is_empty() {
return false;
}
exclude_patterns.iter().any(|pattern| {
wildcard_match(pattern, relative)
|| wildcard_match(pattern, &format!("{relative}/"))
|| wildcard_match(pattern, &format!("{relative}/placeholder"))
})
}
fn should_include_preview_file(
relative: &str,
include_patterns: &[String],
exclude_patterns: &[String],
) -> bool {
if relative.is_empty() {
return true;
}
let included = include_patterns.is_empty()
|| include_patterns
.iter()
.any(|pattern| wildcard_match(pattern, relative));
let excluded = exclude_patterns
.iter()
.any(|pattern| wildcard_match(pattern, relative));
included && !excluded
}
fn wildcard_match(pattern: &str, candidate: &str) -> bool {
let pattern = pattern.trim().replace('\\', "/");
let candidate = candidate.trim().replace('\\', "/");
let p = pattern.as_bytes();
let c = candidate.as_bytes();
let mut pi = 0usize;
let mut ci = 0usize;
let mut star: Option<usize> = None;
let mut star_match = 0usize;
while ci < c.len() {
if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
pi += 1;
ci += 1;
} else if pi < p.len() && p[pi] == b'*' {
while pi < p.len() && p[pi] == b'*' {
pi += 1;
}
star = Some(pi);
star_match = ci;
} else if let Some(star_pi) = star {
star_match += 1;
ci = star_match;
pi = star_pi;
} else {
return false;
}
}
while pi < p.len() && p[pi] == b'*' {
pi += 1;
}
pi == p.len()
}
fn escape_html(value: &str) -> String {
value
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
#[derive(Clone)]
struct SubmoduleRow {
name: String,
relative_path: String,
files_analyzed: u64,
code_lines: u64,
comment_lines: u64,
blank_lines: u64,
total_physical_lines: u64,
html_url: Option<String>,
}
#[derive(Template)]
#[template(
source = r##"
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>OxideSLOC | tmp-sloc</title>
<link rel="icon" type="image/png" href="/images/logo/small-logo.png">
<style nonce="{{ csp_nonce }}">
:root {
--bg: #efe9e2;
--surface: #fcfaf7;
--surface-2: #f7f0e8;
--surface-3: #efe3d5;
--line: #dfcfbf;
--line-strong: #cfb29c;
--text: #2f241c;
--muted: #6f6257;
--muted-2: #917f71;
--nav: #b85d33;
--nav-2: #7a371b;
--accent: #2563eb;
--accent-2: #1d4ed8;
--oxide: #b85d33;
--oxide-2: #8f4220;
--success-bg: #eaf9ee;
--success-text: #1c8746;
--warn-bg: #fff2d8;
--warn-text: #926000;
--danger-bg: #fdeaea;
--danger-text: #b33b3b;
--shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
--shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
--radius: 14px;
}
body.dark-theme {
--bg: #1b1511;
--surface: #261c17;
--surface-2: #2d221d;
--surface-3: #372922;
--line: #524238;
--line-strong: #6c5649;
--text: #f5ece6;
--muted: #c7b7aa;
--muted-2: #aa9485;
--nav: #b85d33;
--nav-2: #7a371b;
--accent: #6f9bff;
--accent-2: #4a78ee;
--oxide: #d37a4c;
--oxide-2: #b35428;
--success-bg: #163927;
--success-text: #8fe2a8;
--warn-bg: #3c2d11;
--warn-text: #f3cb75;
--danger-bg: #3d1f1f;
--danger-text: #ff9f9f;
--shadow: 0 14px 28px rgba(0,0,0,0.28);
--shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
}
* { box-sizing: border-box; }
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); }
html { overflow-y: scroll; }
body { overflow-x: clip; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
.top-nav, .page, .loading { position: relative; z-index: 2; }
.background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
.background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
.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); }
.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; }
.brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
.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)); }
.brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
.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; line-height: 1.2; margin-top: 2px; }
.nav-project-slot { display:flex; justify-content:center; min-width:0; }
.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; }
.nav-project-pill.visible { display:inline-flex; }
.nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
.nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
@media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
@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; } }
.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; }
a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
.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; }
.theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
.theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
.theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
.theme-toggle .icon-sun { display:none; }
body.dark-theme .theme-toggle .icon-sun { display:block; }
body.dark-theme .theme-toggle .icon-moon { display:none; }
.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;}
.settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
.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);}
.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;}
.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;}
.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;}
.scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
.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;}
.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);}
.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;}
.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;}
.tz-select:focus{border-color:var(--oxide);}
.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; }
.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;}
.page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 36px; width: 100%; display: flex; flex-direction: column; }
@media (max-width: 1920px) { .top-nav-inner { max-width: 1500px; } .page { max-width: 1500px; } }
.summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
.workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
.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; }
.workbench-box:hover { transform: translateY(-3px); box-shadow: 0 14px 36px rgba(77,44,20,0.18); }
body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
.wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; position: relative; z-index: 25; }
.wb-stats-header { padding: 10px 24px 0; }
.wb-stats-title { font-size: 9px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
.ws-left { display:flex; align-items:stretch; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
.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; }
.ws-stat:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
.ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
.ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
.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; }
body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
.ws-stat-analyzers { position: relative; }
.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; }
.ws-stat-analyzers:hover .ws-lang-tooltip { display:block; }
.ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:4px; }
.ws-lang-tooltip-desc { font-size:12px; color:var(--text); line-height:1.45; margin-bottom:10px; }
.ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
.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; }
body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
.ws-divider { display: none; }
.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%; }
.ws-path-link:hover { color:var(--oxide); }
body.dark-theme .ws-path-link { color:var(--oxide); }
.ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
.ws-stat-output .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
.ws-stat-clamp { max-width: 200px; overflow: hidden; }
.ws-stat-clamp .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
.ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
.ws-mini-box-sm .ws-mini-label { font-size:9px; }
.ws-mini-box-sm .ws-mini-value { font-size:13px; }
.ws-mini-box-lg { flex:2 1 0; }
.ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.ws-mini-box-br { flex:1.5 1 0; }
.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); }
.scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; }
.path-scope-grid { display:grid; grid-template-columns: 42% auto auto 1px auto; gap:0 8px; align-items:center; }
#path.drag-over { background: rgba(37,99,235,0.05) !important; border-color: var(--accent) !important; box-shadow: 0 0 0 3px rgba(37,99,235,0.15) !important; }
.path-scope-grid > input[type=text] { width:100%; min-width:0; }
.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; }
.git-source-banner svg { width:15px; height:15px; stroke:#7c3aed; fill:none; stroke-width:2; flex-shrink:0; }
.git-source-banner strong { font-weight:800; color:var(--text); }
.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; }
body.dark-theme .git-source-banner code { background:rgba(167,139,250,0.10); color:#c4b5fd; border-color:rgba(167,139,250,0.22); }
.git-source-banner a { color:var(--oxide-2); font-weight:700; text-decoration:none; margin-left:auto; font-size:12px; }
.git-source-banner a:hover { text-decoration:underline; }
.git-locked-input { background:var(--surface-2) !important; cursor:default; color:var(--muted) !important; }
.path-scope-sep { background:var(--line); margin:4px 14px; }
.recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
.recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
.step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
.ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
.ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
.ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
.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; }
.ws-mini-box:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
.ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
.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; }
.wb-ftip-arrow { position:absolute; bottom:100%; left:20px; width:0; height:0; border:6px solid transparent; border-bottom-color:var(--line-strong); }
.wb-ftip-arrow::after { content:''; position:absolute; top:2px; left:-5px; width:0; height:0; border:5px solid transparent; border-bottom-color:var(--surface); }
[data-wb-tip] { cursor:help; }
.ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
.ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
.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; }
.ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
.ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
.summary-card, .card, .step-nav, .explainer-card, .review-card, .workspace-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; }
.summary-card:hover, .workspace-card:hover, .explainer-card:hover, .review-card:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); transform: translateY(-2px); }
.card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
.side-info-card { padding: 18px; }
.side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
.side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
.summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
.summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
.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); }
.summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
.summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
.coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
.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; }
.layout { display:grid; grid-template-columns: 244px minmax(0, 1fr); gap: 18px; align-items:stretch; flex: 1; min-height: 0; }
.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; }
.side-stack::-webkit-scrollbar { display: none; }
.step-nav { padding: 20px 16px; }
.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); }
.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; }
.step-button:hover { background: var(--surface-2); }
.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); }
.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; }
.step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
.step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
.step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
.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); }
.step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
.step-nav-sum-row:last-child { border-bottom:none; }
.step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
.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; }
.step-steps-divider { height:1px; background:var(--line); margin: 14px 4px 0; }
.quick-scan-divider { height:1px; background:var(--line); margin: 20px 4px 6px; }
.quick-scan-section { padding: 10px 4px 14px; }
.quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
.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; }
.quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
.quick-scan-btn:active { transform:translateY(0); }
.quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
.quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
.step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
@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);} }
@keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
.step-nav > button:nth-child(2) { animation-delay: 0.04s; }
.step-nav > button:nth-child(3) { animation-delay: 0.09s; }
.step-nav > button:nth-child(4) { animation-delay: 0.14s; }
.step-nav > button:nth-child(5) { animation-delay: 0.19s; }
.step-check { margin-left:auto; width:14px; height:14px; stroke:#16a34a; fill:none; opacity:0; transition:opacity 0.22s ease; flex-shrink:0; }
.step-button.done .step-check { opacity:1; }
.step-button.done .step-num { background:rgba(34,197,94,0.16); color:#16a34a; }
.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; }
.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; }
.sidebar-scroll-divider { height:1px; background:var(--line); margin: 12px 4px; }
.sidebar-scroll-btn { display:flex; align-items:center; justify-content:center; gap:5px; width:100%; padding:7px 10px; border-radius:9px; border:1px solid var(--line); background:var(--surface-2); color:var(--muted); font-size:11px; font-weight:700; text-decoration:none; cursor:pointer; transition:background 0.15s ease,border-color 0.15s ease,color 0.15s ease; }
.sidebar-scroll-btn:hover { background:var(--surface-3); border-color:var(--line-strong); color:var(--text); text-decoration:none; }
.sidebar-scroll-btn svg { width:12px; height:12px; stroke:currentColor; fill:none; stroke-width:2.5; flex-shrink:0; }
.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; }
body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
.card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
.wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
.wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
.wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
.wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
.wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
.wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
.card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
.card-subtitle { margin: 10px 0 0; padding-bottom: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
.card-body { padding: 22px; }
.wizard-step { display:none; opacity: 0; transform: translateY(8px); }
.wizard-step.active { display:block; animation: stepFade 220ms ease both; }
@keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
.section { margin-bottom: 12px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
.section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
.field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
.field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
.field { min-width:0; }
label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
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; }
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); }
input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
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); }
textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
textarea.glob-textarea { font-size: 13px; padding: 10px 12px; }
.glob-label-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:6px; min-height:28px; }
.hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
.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; }
.path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
.path-history-badge.new { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
.path-history-badge.warning { background: #fff0f0; color: #b91c1c; border: 1px solid #fca5a5; font-weight: 700; padding: 8px 14px; border-radius: 8px; }
body.dark-theme .path-history-badge.warning { background: #3a1010; color: #f87171; border-color: #7f1d1d; }
.input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
.input-group.compact { grid-template-columns: 1fr auto auto; }
.path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
.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)); }
.path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
.path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
.path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
.path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
.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; }
.full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
.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; }
.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); }
.mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
.mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
button.secondary { background: var(--surface); }
button.next-step { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
button.next-step:hover { opacity: 0.88; box-shadow: 0 6px 20px rgba(0,0,0,0.22); transform: translateY(-1px); }
button.prev-step { color: var(--nav); border-color: var(--nav); background: var(--surface); }
button.prev-step:hover { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
.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); }
.section + .wizard-actions { border-top: none; padding-top: 0; }
.wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; }
.field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
.field-help-grid.coupled-help { margin-top: 12px; }
.field-help-grid.preset-grid { align-items: start; }
.preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:start; margin-bottom: 16px; }
.preset-inline-row .field { margin: 0; }
.preset-inline-row .explainer-card { margin: 0; }
.preset-inline-row .toggle-card { display:flex; flex-direction:column; }
.preset-inline-row .explainer-card { display:flex; flex-direction:column; }
.preset-kv-row { display:flex; align-items:flex-start; gap:20px; margin-bottom:16px; }
.preset-kv-row > :first-child { flex:0 0 35%; min-width:0; }
.preset-kv-row > :last-child { flex:1; min-width:0; }
.output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
.output-field-row .field { margin: 0; }
.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; }
.output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
.step3-subtitle { margin-bottom: 10px; max-width: none; }
.counting-intro { margin-bottom: 8px; max-width: none; }
.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; }
.counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
.counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
.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; }
.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; }
.section-spacer-top { margin-top: 28px; }
.explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
.explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
.explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
.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); }
.preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
.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; }
.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; }
.glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
.glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
.glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
.glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
.lbl-opt { font-weight:400; font-size:12px; color:var(--muted); margin-left:4px; }
.include-scope-badge { display:flex; align-items:center; gap:7px; padding:7px 12px; border-radius:8px; font-size:12px; font-weight:700; margin-bottom:7px; transition:background .2s,color .2s,border-color .2s; }
.include-scope-badge.scope-all { background:rgba(42,104,70,0.1); border:1px solid rgba(42,104,70,0.25); color:#2a6846; }
.include-scope-badge.scope-narrow { background:rgba(184,93,51,0.08); border:1px solid rgba(184,93,51,0.22); color:var(--nav,#b85d33); }
body.dark-theme .include-scope-badge.scope-all { background:rgba(90,186,138,0.12); border-color:rgba(90,186,138,0.3); color:#5aba8a; }
body.dark-theme .include-scope-badge.scope-narrow { background:rgba(210,130,70,0.12); border-color:rgba(210,130,70,0.3); color:#e0a060; }
.toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
.checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
.checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
.scan-rules-grid { display:grid; gap: 0; margin-top: 4px; padding-bottom: 24px; }
.scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
.scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
.scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
.advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
.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); }
.advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
.toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
.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; }
.docstring-example-inset .field-help-title { margin-bottom: 6px; }
.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; width:100%; box-sizing:border-box; }
.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; }
.always-tracked-tip-body { flex:1; min-width:0; }
.always-tracked-tip-body .field-help-title { color: var(--accent-2); }
.always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
.always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
.always-tracked-metrics-row { display:grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap:6px 18px; margin:8px 0 0; }
.always-tracked-metrics-row > div { font-size:13px; color:var(--muted); line-height:1.5; }
.always-tracked-metrics-row strong { display:block; font-size:13px; color:var(--text); margin-bottom:2px; white-space:nowrap; }
@media (max-width:900px) { .always-tracked-metrics-row { grid-template-columns: repeat(2,minmax(0,1fr)); } }
.advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
.advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
.advanced-rule-description strong { color: var(--text); }
.output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
.review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
.review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
.review-link:hover { text-decoration: underline; }
.artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
.review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
.review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
.review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
.review-card h4 { margin: 0 0 8px; font-size: 17px; }
.review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
.review-card ul { padding-left: 18px; margin: 0; }
.review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
.review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
.review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
.review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
.review-card { min-height: 0; }
.scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
.scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
.scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
.language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
.lang-overflow-chip { position:relative; cursor:default; }
.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; }
.lang-overflow-chip:hover .lang-overflow-tip { display:block; }
.git-inline-row { align-items:start; }
.mixed-line-card { display:flex; flex-direction:column; }
.preset-inline-row .toggle-card { justify-content: center; }
.explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
.explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
.explorer-toolbar.compact { padding: 0; border-bottom: none; }
.explorer-title { font-size: 18px; font-weight: 850; }
.explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
.explorer-subtitle.wide { max-width: none; }
.preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
.better-spacing { align-items:flex-start; justify-content:flex-end; }
.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; }
.badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
.badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
.badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
.badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
.scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
.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; }
.scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
.scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
.scope-stat-button.supported { background: var(--success-bg); }
.scope-stat-button.skipped { background: var(--warn-bg); }
.scope-stat-button.unsupported { background: var(--danger-bg); }
.scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
.scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
.scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
[data-tooltip] { position: relative; }
[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); }
[data-tooltip]:hover::after { display: block; }
.scope-stat-button[data-tooltip] { cursor: pointer; }
.badge[data-tooltip] { cursor: help; }
.explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
.explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
.explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
.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; }
.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; }
code { display:inline-block; margin-top:0; padding:2px 7px; }
.explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
.language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
.language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
.language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
.language-pill.muted-pill { color: var(--muted); }
button.language-pill { appearance:none; cursor:pointer; }
.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); }
.file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
.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; }
.file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
.file-explorer-search-row { margin-left: auto; }
.explorer-filter-select { min-width: 170px; width: 170px; }
.explorer-search { min-width: 300px; width: 300px; }
.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); }
.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; }
.tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
.tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
.tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
.file-explorer-tree { max-height: 640px; overflow:auto; }
.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); }
.tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
.tree-row.hidden-by-filter { display:none !important; }
.tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 4px 0; }
.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; }
.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; }
.tree-toggle:hover { color: var(--text); background: var(--surface-3); }
.tree-bullet { color: var(--muted-2); width: 22px; text-align:center; flex: 0 0 22px; font-size: 7px; opacity: 0.5; }
.tree-node { display:inline-flex; align-items:center; min-width:0; }
.tree-node-dir { color: var(--text); font-weight: 800; }
.tree-node-supported { color: var(--success-text); }
.tree-node-skipped { color: var(--warn-text); }
.tree-node-unsupported { color: var(--danger-text); }
.tree-node-more { color: var(--muted-2); font-style: italic; }
.tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 11px; }
.tree-status-cell .badge { font-size: 10px; padding: 1px 7px; }
.tree-status-cell { display:flex; justify-content:flex-start; }
.preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
.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; }
.preview-loading { display:flex; align-items:center; gap:12px; padding:14px 16px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
.preview-spinner { width:18px; height:18px; border:2.5px solid var(--line); border-top-color:var(--oxide); border-radius:50%; animation:prevSpin 0.75s linear infinite; flex:0 0 18px; }
@keyframes prevSpin { to { transform:rotate(360deg); } }
.preview-loading-text { flex:1; min-width:0; }
.preview-loading-msg { font-size:13px; color:var(--text); font-weight:600; }
.preview-loading-elapsed { font-size:11px; color:var(--muted); margin-top:2px; }
.scope-preview-divider { height:1px; background:var(--line); opacity:0.5; margin-top:22px; margin-bottom:22px; }
.cov-scan-status { border-radius:10px; font-size:12.5px; margin-top:10px; }
.cov-scan-idle { display:none; }
.cov-scan-inner { display:flex; align-items:flex-start; gap:9px; padding:10px 13px; }
.cov-scan-icon { flex:0 0 15px; width:15px; height:15px; display:flex; align-items:center; justify-content:center; margin-top:1px; }
.cov-scan-body { flex:1; min-width:0; line-height:1.4; }
.cov-scan-title { font-weight:600; font-size:12.5px; }
.cov-scan-sub { color:var(--muted); font-size:11.5px; margin-top:2px; }
.cov-scan-actions { margin-top:7px; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
.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; }
.cov-scan-use:hover { opacity:.75; }
.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; }
.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; }
@keyframes cov-pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
.cov-scan-scanning { background:rgba(100,100,100,0.06); border:1px solid var(--line); }
.cov-scan-scanning .cov-scan-title { color:var(--muted); }
.cov-scan-scanning .cov-scan-icon svg { animation:cov-pulse 1.3s ease-in-out infinite; }
.cov-scan-found { background:rgba(34,113,60,0.07); border:1px solid rgba(34,113,60,0.22); }
.cov-scan-found .cov-scan-title,.cov-scan-found .cov-scan-use { color:#1f6b3a; }
.cov-scan-found .cov-scan-use { border-color:#1f6b3a; }
.cov-scan-found .cov-scan-tool { background:rgba(34,113,60,0.12); color:#1f6b3a; }
body.dark-theme .cov-scan-found { background:rgba(34,113,60,0.1); border-color:rgba(90,186,138,0.25); }
body.dark-theme .cov-scan-found .cov-scan-title,body.dark-theme .cov-scan-found .cov-scan-use { color:#5aba8a; }
body.dark-theme .cov-scan-found .cov-scan-use { border-color:#5aba8a; }
body.dark-theme .cov-scan-found .cov-scan-tool { background:rgba(90,186,138,0.12); color:#5aba8a; }
.cov-scan-found .cov-scan-remove { color:#8b2020!important; border-color:#8b2020!important; }
body.dark-theme .cov-scan-found .cov-scan-remove { color:#e07070!important; border-color:#e07070!important; }
.cov-scan-hint { background:rgba(160,110,0,0.06); border:1px solid rgba(160,110,0,0.22); }
.cov-scan-hint .cov-scan-title { color:#7a5e00; }
.cov-scan-hint .cov-scan-tool { background:rgba(160,110,0,0.1); color:#7a5e00; }
.cov-scan-hint .cov-scan-cmd { background:rgba(0,0,0,0.07); }
body.dark-theme .cov-scan-hint { background:rgba(200,160,0,0.08); border-color:rgba(200,160,0,0.22); }
body.dark-theme .cov-scan-hint .cov-scan-title { color:#d4a017; }
body.dark-theme .cov-scan-hint .cov-scan-tool { background:rgba(200,160,0,0.12); color:#d4a017; }
body.dark-theme .cov-scan-hint .cov-scan-cmd { background:rgba(255,255,255,0.07); }
.cov-scan-none { background:rgba(100,100,100,0.05); border:1px solid var(--line); }
.cov-scan-none .cov-scan-title { color:var(--muted); font-weight:500; }
.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); }
.loading.active { display:flex; }
.loading-card { width: min(840px, calc(100vw - 40px)); border-radius: 20px; border: 1px solid var(--line); background: var(--surface); box-shadow: 0 24px 56px rgba(0,0,0,0.26); padding: 42px 48px; }
.progress-bar { width:100%; height:9px; margin-top:0; background: var(--surface-3); border-radius:999px; overflow:hidden; margin-bottom:0; }
.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; }
@keyframes pulseBar { 0% { transform: translateX(-100%) scaleX(0.5); } 50% { transform: translateX(0%) scaleX(0.5); } 100% { transform: translateX(200%) scaleX(0.5); } }
.lc-badge { display:inline-flex;align-items:center;gap:10px;background:linear-gradient(135deg,rgba(211,122,76,0.16),rgba(184,93,51,0.08));border:1.5px solid rgba(211,122,76,0.44);border-radius:10px;padding:8px 18px 8px 13px;font-size:12px;font-weight:800;color:var(--oxide,#d37a4c);text-transform:uppercase;letter-spacing:.07em;margin-bottom:20px;box-shadow:0 2px 16px rgba(211,122,76,0.16); }
.lc-dot-wrap { position:relative;width:14px;height:14px;flex:0 0 auto; }
.lc-dot { position:absolute;inset:2px;border-radius:50%;background:var(--oxide,#d37a4c);animation:lcPulse 1.4s ease-in-out infinite; }
.lc-dot-ring { position:absolute;inset:-3px;border-radius:50%;border:2px solid var(--oxide,#d37a4c);animation:lcRing 1.4s ease-out infinite; }
@keyframes lcPulse { 0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.45;transform:scale(0.7);} }
@keyframes lcRing { 0%{opacity:0.65;transform:scale(0.5);}100%{opacity:0;transform:scale(2.2);} }
.lc-title { font-size:1.44rem;font-weight:800;margin:0 0 6px; }
.lc-sub { color:var(--muted);font-size:0.9rem;margin:0 0 18px; }
.lc-path { background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:10px 16px;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px;color:var(--muted);word-break:break-all;margin-bottom:18px;display:flex;align-items:center;gap:10px; }
.lc-metrics { display:flex;gap:12px;margin-bottom:16px; }
.lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:14px 18px;flex:1 1 0;min-width:0; }
.lc-metric-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:5px; }
.lc-metric-value { font-size:1.2rem;font-weight:800;color:var(--text); }
.lc-stage-desc { font-size:12px;color:var(--muted);background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:9px 14px;margin-bottom:18px;line-height:1.5;transition:opacity .3s; }
.lc-steps { display:flex;align-items:center;gap:0;margin-bottom:18px; }
.lc-step { display:flex;align-items:center;gap:6px;padding:5px 12px;border-radius:999px;color:var(--muted);border:1.5px solid transparent;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;transition:all .25s; }
.lc-step.active { color:var(--oxide,#d37a4c);background:rgba(211,122,76,0.1);border-color:rgba(211,122,76,0.32); }
.lc-step.done { color:var(--muted);opacity:0.55; }
.lc-step-num { width:18px;height:18px;border-radius:50%;background:rgba(150,140,130,0.2);color:var(--muted);display:inline-flex;align-items:center;justify-content:center;font-size:10px;font-weight:900;flex:0 0 auto; }
.lc-step.active .lc-step-num { background:var(--oxide,#d37a4c);color:#fff; }
.lc-step.done .lc-step-num { background:rgba(80,180,100,0.22);color:#2d8a45; }
.lc-step-arrow { color:var(--line-strong,#ccc);font-size:16px;padding:0 8px;flex:0 0 auto;line-height:1; }
.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; }
.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; }
.lc-err strong { display:block;color:#8b1f1f;margin-bottom:4px;font-size:13px; }
.lc-err p { margin:0;font-size:12px;color:var(--muted); }
.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; }
.lc-cancelled strong { display:block;color:var(--muted);margin-bottom:2px;font-size:13px; }
.lc-actions { display:flex;gap:10px;flex-wrap:wrap;margin-top:14px; }
.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; }
.quick-excl-row { display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px; }
.quick-excl-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;margin-right:2px; }
.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; }
.quick-excl-chip:hover { background:rgba(37,99,235,0.15);border-color:rgba(37,99,235,0.4); }
.quick-excl-chip.active { background:rgba(37,99,235,0.18);border-color:rgba(37,99,235,0.55);opacity:0.6;cursor:default; }
.quick-excl-chip-all { background:rgba(180,80,20,0.08);border-color:rgba(180,80,20,0.25);color:var(--nav,#b85d33); }
.quick-excl-chip-all:hover { background:rgba(180,80,20,0.16);border-color:rgba(180,80,20,0.45); }
body.dark-theme .quick-excl-chip { background:rgba(111,155,255,0.1);border-color:rgba(111,155,255,0.25); }
body.dark-theme .quick-excl-chip-all { background:rgba(210,120,60,0.1);border-color:rgba(210,120,60,0.3); }
.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; }
.lc-cancel-btn:hover { color:#c0392b;border-color:#c0392b; }
body.dark-theme .lc-cancelled { background:rgba(80,80,80,0.12);border-color:rgba(150,150,150,0.2); }
.hidden { display:none !important; }
.site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
.site-footer a{color:var(--muted);}
@media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
@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; } }
.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;}
@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));}}
.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;}
.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; }
.submodule-preview-label { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:700; color:var(--text); white-space:nowrap; }
.submodule-preview-label svg { width:15px; height:15px; stroke:var(--accent-2); fill:none; stroke-width:2; flex:0 0 auto; }
.submodule-preview-chips { display:flex; flex-wrap:wrap; gap:8px; }
.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; }
.submodule-preview-chip:hover { background:rgba(37,99,235,0.18); }
.submodule-preview-chip.active { background:rgba(37,99,235,0.22); box-shadow:0 0 0 2px rgba(37,99,235,0.35); }
.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; }
.submodule-chip-tooltip::after { content:''; position:absolute; top:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-top-color:var(--text); }
.submodule-preview-chip:hover .submodule-chip-tooltip { opacity:1; }
.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; }
.submodule-base-repo-btn:hover { background:rgba(77,44,20,0.18); }
.path-info-row { display:flex; align-items:center; gap:6px; margin-top:6px; border-bottom:none; padding:0; }
.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; }
.info-icon-btn svg { width:14px; height:14px; flex:0 0 auto; opacity:.75; }
.info-icon-btn:hover { color:var(--text); }
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); }
body.dark-theme .submodule-preview-chip { background:rgba(37,99,235,0.18); border-color:rgba(111,155,255,0.3); }
body.dark-theme .submodule-base-repo-btn { background:rgba(255,255,255,0.07); border-color:rgba(255,255,255,0.18); }
.toast-success{display:flex;align-items:center;gap:10px;background:#e8f5ed;border:1px solid #a3d9b1;border-radius:10px;padding:10px 16px;font-size:13px;color:#1a5c35;font-weight:600;}
body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
.toast-error{display:flex;align-items:center;gap:10px;background:#fde8e8;border:1px solid #f5a3a3;border-radius:10px;padding:10px 16px;font-size:13px;color:#7a1a1a;font-weight:600;}
body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
#offline-file-banner{display:none;position:sticky;top:0;z-index:9999;background:#fff8e1;border-bottom:2px solid #f0b429;padding:10px 20px;font-size:13px;font-weight:600;color:#7a5000;align-items:center;gap:12px;box-shadow:0 2px 10px rgba(0,0,0,0.12);}
#offline-file-banner.show{display:flex;}
#offline-file-banner svg{flex-shrink:0;width:20px;height:20px;stroke:#f0b429;fill:none;stroke-width:2;}
#offline-file-banner .ofb-text{flex:1;}
#offline-file-banner .ofb-text a{color:#b35c00;font-weight:700;text-decoration:underline;}
#offline-file-banner .ofb-code{background:rgba(0,0,0,0.08);padding:1px 5px;border-radius:4px;font-size:12px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;}
#offline-file-banner .ofb-dismiss{margin-left:auto;background:none;border:1px solid #d4950a;border-radius:6px;color:#7a5000;font-size:12px;font-weight:700;padding:3px 10px;cursor:pointer;white-space:nowrap;}
#offline-file-banner .ofb-dismiss:hover{background:#feefc3;}
body.dark-theme #offline-file-banner{background:#2d2200;border-bottom-color:#c98a00;color:#e8c96a;}
body.dark-theme #offline-file-banner svg{stroke:#c98a00;}
body.dark-theme #offline-file-banner .ofb-text a{color:#f0c040;}
body.dark-theme #offline-file-banner .ofb-code{background:rgba(255,255,255,0.08);}
body.dark-theme #offline-file-banner .ofb-dismiss{border-color:#9a6a00;color:#e8c96a;}
body.dark-theme #offline-file-banner .ofb-dismiss:hover{background:rgba(240,180,0,0.12);}
</style>
</head>
<body id="page-top">
<div id="offline-file-banner" role="alert">
<svg viewBox="0 0 24 24" aria-hidden="true"><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>
<span class="ofb-text">
Charts, images, and navigation require the oxide-sloc server.
Start it with <span class="ofb-code">cargo run -p oxide-sloc</span> or <span class="ofb-code">bash run.sh</span>,
then open this run at <a href="http://127.0.0.1:4317" target="_blank" rel="noopener">http://127.0.0.1:4317</a>.
The metric tables below are fully readable without the server.
</span>
<button class="ofb-dismiss" id="ofb-dismiss-btn" type="button">Dismiss</button>
</div>
<script nonce="{{ csp_nonce }}">(function(){if(location.protocol==='file:'){var b=document.getElementById('offline-file-banner');if(b)b.classList.add('show');var d=document.getElementById('ofb-dismiss-btn');if(d)d.addEventListener('click',function(){b.classList.remove('show');});}})();</script>
<div class="background-watermarks" aria-hidden="true">
<img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" />
</div>
<div class="code-particles" id="code-particles" aria-hidden="true"></div>
<div class="top-nav">
<div class="top-nav-inner">
<a class="brand" href="/">
<img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
<div class="brand-copy">
<div class="brand-title">OxideSLOC</div>
<div class="brand-subtitle">local code analysis - metrics, history and reports</div>
</div>
</a>
<div class="nav-project-slot">
<div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
<span class="nav-project-label">Project</span>
<span class="nav-project-value" id="nav-project-title">tmp-sloc</span>
</div>
</div>
<div class="nav-status">
<a class="nav-pill" href="/">Home</a>
<div class="nav-dropdown">
<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>
<div class="nav-dropdown-menu">
<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>
</div>
</div>
<a class="nav-pill" href="/compare-scans">Compare Scans</a>
<a class="nav-pill" href="/test-metrics">Test Metrics</a>
<div class="nav-dropdown">
<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>
<div class="nav-dropdown-menu">
<a href="/integrations"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
</div>
</div>
<div class="server-status-wrap" id="server-status-wrap">
<div class="nav-pill server-online-pill" id="server-status-pill">
<span class="status-dot" id="status-dot"></span>
<span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
<span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
</div>
<div class="server-status-tip">
{% if server_mode %}
OxideSLOC is running in server mode — accessible on your LAN.
{% else %}
OxideSLOC is running locally — only accessible from this machine.
{% endif %}
<span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
</div>
</div>
<button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
<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>
</button>
<button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
<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>
<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>
</button>
</div>
</div>
</div>
<div class="loading" id="loading">
<div class="loading-card">
<div class="lc-badge" id="lc-badge"><span class="lc-dot-wrap"><span class="lc-dot"></span><span class="lc-dot-ring"></span></span>Analysis running</div>
<h2 class="lc-title" id="lc-title">Analyzing your project…</h2>
<p class="lc-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
<div class="lc-path" id="lc-path"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" style="flex:0 0 auto;opacity:0.45"><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 id="lc-path-text"></span></div>
<div class="lc-steps" id="lc-steps">
<div class="lc-step active" id="lc-step-1"><span class="lc-step-num">1</span>Discover</div>
<div class="lc-step-arrow">›</div>
<div class="lc-step" id="lc-step-2"><span class="lc-step-num">2</span>Analyze</div>
<div class="lc-step-arrow">›</div>
<div class="lc-step" id="lc-step-3"><span class="lc-step-num">3</span>Report</div>
<div class="lc-step-arrow">›</div>
<div class="lc-step" id="lc-step-4"><span class="lc-step-num">4</span>Done</div>
</div>
<div class="lc-stage-desc" id="lc-stage-desc">Initializing language analyzers and loading configuration…</div>
<div class="lc-metrics" id="lc-metrics">
<div class="lc-metric"><div class="lc-metric-label">Elapsed</div><div class="lc-metric-value" id="lc-elapsed">0s</div></div>
<div class="lc-metric"><div class="lc-metric-label">Phase</div><div class="lc-metric-value" id="lc-phase">Starting</div></div>
<div class="lc-metric hidden" id="lc-files-card"><div class="lc-metric-label">Files</div><div class="lc-metric-value" id="lc-files">0</div></div>
<div class="lc-metric hidden" id="lc-speed-card"><div class="lc-metric-label">Files/sec</div><div class="lc-metric-value" id="lc-speed">—</div></div>
</div>
<div class="progress-bar" id="lc-progress-bar"><span></span></div>
<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>
<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>
<div class="lc-cancelled hidden" id="lc-cancelled"><strong>Scan cancelled</strong></div>
<div class="lc-actions hidden" id="lc-actions">
<button class="primary" id="lc-dismiss" type="button">Try Again</button>
<a href="/view-reports" class="lc-outline-btn">View Reports</a>
</div>
<button class="lc-cancel-btn" id="lc-cancel-btn" type="button">
<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>
Cancel scan
</button>
</div>
</div>
<div class="page">
<div class="workbench-strip">
<div class="workbench-box wb-stats">
<div class="wb-stats-header" data-wb-tip="Summarizes this session: active language analyzers, server mode, selected project, and output destination.">
<span class="wb-stats-title">Analysis session</span>
</div>
<div class="ws-left">
<div class="ws-stat ws-stat-analyzers">
<span class="ws-label">Analyzers</span>
<span class="ws-value">
<span class="ws-badge">60 languages</span>
</span>
<div class="ws-lang-tooltip">
<div class="ws-lang-tooltip-hdr">60 supported languages</div>
<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>
<div class="ws-lang-grid">
<span class="ws-lang-item">Assembly</span>
<span class="ws-lang-item">C</span>
<span class="ws-lang-item">C++</span>
<span class="ws-lang-item">C#</span>
<span class="ws-lang-item">Clojure</span>
<span class="ws-lang-item">CSS</span>
<span class="ws-lang-item">Dart</span>
<span class="ws-lang-item">Dockerfile</span>
<span class="ws-lang-item">Elixir</span>
<span class="ws-lang-item">Erlang</span>
<span class="ws-lang-item">F#</span>
<span class="ws-lang-item">Go</span>
<span class="ws-lang-item">Groovy</span>
<span class="ws-lang-item">Haskell</span>
<span class="ws-lang-item">HTML</span>
<span class="ws-lang-item">Java</span>
<span class="ws-lang-item">JavaScript</span>
<span class="ws-lang-item">Julia</span>
<span class="ws-lang-item">Kotlin</span>
<span class="ws-lang-item">Lua</span>
<span class="ws-lang-item">Makefile</span>
<span class="ws-lang-item">Nim</span>
<span class="ws-lang-item">Obj-C</span>
<span class="ws-lang-item">OCaml</span>
<span class="ws-lang-item">Perl</span>
<span class="ws-lang-item">PHP</span>
<span class="ws-lang-item">PowerShell</span>
<span class="ws-lang-item">Python</span>
<span class="ws-lang-item">R</span>
<span class="ws-lang-item">Ruby</span>
<span class="ws-lang-item">Rust</span>
<span class="ws-lang-item">Scala</span>
<span class="ws-lang-item">SCSS</span>
<span class="ws-lang-item">Shell</span>
<span class="ws-lang-item">SQL</span>
<span class="ws-lang-item">Svelte</span>
<span class="ws-lang-item">Swift</span>
<span class="ws-lang-item">TypeScript</span>
<span class="ws-lang-item">Vue</span>
<span class="ws-lang-item">XML</span>
<span class="ws-lang-item">Zig</span>
<span class="ws-lang-item">Solidity</span>
<span class="ws-lang-item">Protobuf</span>
<span class="ws-lang-item">HCL</span>
<span class="ws-lang-item">GraphQL</span>
<span class="ws-lang-item">Ada</span>
<span class="ws-lang-item">VHDL</span>
<span class="ws-lang-item">Verilog</span>
<span class="ws-lang-item">Tcl</span>
<span class="ws-lang-item">Pascal</span>
<span class="ws-lang-item">Visual Basic</span>
<span class="ws-lang-item">Lisp</span>
<span class="ws-lang-item">Fortran</span>
<span class="ws-lang-item">Nix</span>
<span class="ws-lang-item">Crystal</span>
<span class="ws-lang-item">D</span>
<span class="ws-lang-item">GLSL</span>
<span class="ws-lang-item">CMake</span>
<span class="ws-lang-item">Elm</span>
<span class="ws-lang-item">Awk</span>
</div>
</div>
</div>
<div class="ws-divider"></div>
<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>
<div class="ws-divider"></div>
<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.">
<span class="ws-label">Output</span>
<span class="ws-value">
<button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
<span id="ws-output-root">project/sloc</span>
</button>
</span>
</div>
</div>
</div>
<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.">
<div class="ws-history-label">Scan history</div>
<div class="ws-history-inner">
<div class="ws-mini-box ws-mini-box-sm" data-wb-tip="Total completed scan runs recorded for this project since the server started.">
<div class="ws-mini-label">Scans</div>
<div class="ws-mini-value" id="ws-scan-count">—</div>
</div>
<div class="ws-mini-box ws-mini-box-lg" data-wb-tip="Timestamp of the most recently completed scan for this project.">
<div class="ws-mini-label">Last Scan</div>
<div class="ws-mini-value" id="ws-last-scan">—</div>
</div>
<div class="ws-mini-box ws-mini-box-br" data-wb-tip="Git branch name recorded during the most recent scan of this project.">
<div class="ws-mini-label">Branch</div>
<div class="ws-mini-value" id="ws-branch">—</div>
</div>
</div>
</div>
</div>
<div class="layout">
<aside class="side-stack">
<section class="step-nav">
<h3>Guided scan setup</h3>
<div class="sidebar-scroll-divider"></div>
<a href="#page-top" class="sidebar-scroll-btn" aria-label="Scroll to top of page">
<svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="18 15 12 9 6 15"></polyline></svg>
Top of page
</a>
<div class="sidebar-scroll-divider"></div>
<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>
<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>
<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>
<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>
<div class="step-steps-divider"></div>
<div class="step-nav-info" id="step-nav-info">
<div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
<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>
</div>
<div class="step-nav-summary" id="sidebar-summary" style="display:none">
<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>
<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>
<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>
</div>
<div class="quick-scan-divider"></div>
<div class="quick-scan-section">
<div class="quick-scan-label">No customization needed?</div>
<button type="button" id="quick-scan-btn" class="quick-scan-btn">
<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>
Quick Scan
</button>
<div class="quick-scan-hint">Scan immediately with default settings — skips steps 2–4.</div>
</div>
<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>
<div class="sidebar-scroll-divider"></div>
<a href="#page-bottom" class="sidebar-scroll-btn" aria-label="Skip to bottom of page">
<svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>
Skip to bottom
</a>
</section>
</aside>
<section class="card">
<div class="card-header">
<div class="card-title-row">
<div>
<h1 class="card-title">Guided scan configuration</h1>
<p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
</div>
<div class="wizard-progress" aria-label="Scan setup progress">
<div class="wizard-progress-top">
<span class="wizard-progress-label">Setup progress</span>
<span class="wizard-progress-value" id="wizard-progress-value">0%</span>
</div>
<div class="wizard-progress-track">
<div class="wizard-progress-fill" id="wizard-progress-fill"></div>
</div>
</div>
</div>
</div>
<div class="card-body">
<form method="post" action="/analyze" id="analyze-form">
<div class="wizard-step active" data-step="1">
<div class="section">
<div class="section-kicker">Step 1</div>
<h2>Select project and preview scope</h2>
<p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
<div class="field">
<label for="path">Project path</label>
{% if !git_repo.is_empty() %}
<div class="git-source-banner">
<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>
Scanning from Git Browser: <strong>{{ git_repo }}</strong> at ref <code>{{ git_ref }}</code>
<a href="/git-browser">← Back to Git Browser</a>
</div>
{% endif %}
<div class="path-scope-grid">
{% if !git_repo.is_empty() %}
<input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input" required style="grid-column:1/4;" />
<input type="hidden" name="git_repo" value="{{ git_repo }}" />
<input type="hidden" name="git_ref" value="{{ git_ref }}" />
{% else %}
<input id="path" name="path" type="text" value="tests/fixtures/basic" placeholder="/path/to/repository" required />
<button type="button" class="mini-button oxide" id="browse-path">{% if server_mode %}Upload{% else %}Browse{% endif %}</button>
<button type="button" class="mini-button" id="use-sample-path">Use sample</button>
{% endif %}
<div class="path-scope-sep"></div>
<div class="scope-legend-row">
<span class="scope-legend-label">Scope legend:</span>
<span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
<span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
<span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
</div>
</div>
{% if git_repo.is_empty() %}
{% if server_mode %}
<div id="upload-limit-tip" class="hint" style="margin-top:6px;font-size:11px;">
ℹ️ Files are compressed and streamed — no fixed size limit.
</div>
{% endif %}
<div class="path-info-row">
<button type="button" class="info-icon-btn" id="project-size-btn" title="Total disk size of the selected project directory">
<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>
<span id="project-size-text">Project size: —</span>
</button>
</div>
{% else %}
<div class="hint">The source code will be checked out from the remote repository at the specified ref when you run the scan.</div>
{% endif %}
<div id="path-history-badge" class="path-history-badge" style="display:none"></div>
<div id="zero-files-warning" class="path-history-badge warning" style="display:none" role="alert"></div>
</div>
<div class="scope-preview-divider" aria-hidden="true"></div>
<div id="preview-panel">
<div class="preview-error">Loading preview...</div>
</div>
</div>
<div class="section" style="margin-top:14px;">
<div class="preset-inline-row git-inline-row">
<div class="toggle-card" style="margin:0;">
<div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
<h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
<label class="checkbox">
<input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
<div>
<span>Detect and separate git submodules</span>
<div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
</div>
</label>
</div>
<div class="explainer-card prominent" style="margin:0;">
<div class="field-help-title" style="margin-bottom:8px;">What this does</div>
<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>
<div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
path = libs/core
url = https://github.com/org/core.git
[submodule "libs/ui"]
path = libs/ui
url = https://github.com/org/ui.git</div>
</div>
</div>
</div>
<div class="section">
<div class="field-grid">
<div class="field">
<div class="glob-label-row">
<label for="include_globs" style="margin:0;flex-shrink:0;">Include globs <span class="lbl-opt">— optional</span></label>
<div id="include-scope-badge" class="include-scope-badge scope-all" aria-live="polite" style="margin:0;padding:4px 10px;font-size:11px;"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg> All files eligible — no include filter active</div>
</div>
<textarea id="include_globs" name="include_globs" class="glob-textarea" placeholder="Leave blank to scan everything Or narrow scope with patterns: src/**/*.py lib/**/*.js scripts/*.sh"></textarea>
<div class="hint"><strong>Leave blank to scan everything</strong> under the project path. Only add patterns here when you want to limit the scan to specific folders or file types. Patterns are line- or comma-separated and relative to the project path.</div>
</div>
<div class="field">
<div class="glob-label-row">
<label for="exclude_globs" style="margin:0;flex-shrink:0;">Exclude globs</label>
</div>
<textarea id="exclude_globs" name="exclude_globs" class="glob-textarea" placeholder="examples: vendor/** **/*.min.js"></textarea>
<div id="quick-exclude-chips" class="quick-excl-row">
<span class="quick-excl-label">Quick add:</span>
<button type="button" class="quick-excl-chip" data-pattern="third_party/**">third_party/**</button>
<button type="button" class="quick-excl-chip" data-pattern="vendor/**">vendor/**</button>
<button type="button" class="quick-excl-chip" data-pattern="node_modules/**">node_modules/**</button>
<button type="button" class="quick-excl-chip" data-pattern="build/**">build/**</button>
<button type="button" class="quick-excl-chip" data-pattern="target/**">target/**</button>
<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>
</div>
<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>
</div>
</div>
<div class="glob-guidance-grid">
<div class="glob-guidance-card">
<strong>How to read them</strong>
<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>
</div>
<div class="glob-guidance-card">
<strong>Common include examples</strong>
<p><strong>Empty (default)</strong> — scans everything. <code>src/**/*.rs</code> only Rust sources, <code>scripts/*</code> top-level scripts only, <code>tests/**</code> everything under tests.</p>
</div>
<div class="glob-guidance-card">
<strong>Common exclude examples</strong>
<p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
</div>
</div>
</div>
<div class="section" style="margin-top:14px;">
<div class="preset-inline-row git-inline-row">
<div class="toggle-card" style="margin:0;">
<div class="field-help-title" style="margin-bottom:10px;">Coverage</div>
<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>
<div class="field" style="margin:0;">
<div class="input-group compact">
<input type="text" id="coverage_file" name="coverage_file" placeholder="e.g. coverage/lcov.info, coverage.xml" />
<button type="button" class="mini-button oxide" id="browse-coverage">Browse</button>
</div>
<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>
<div id="cov-scan-status" class="cov-scan-status cov-scan-idle" aria-live="polite"></div>
</div>
</div>
<div class="explainer-card prominent" style="margin:0;">
<div class="field-help-title" style="margin-bottom:8px;">What this does</div>
<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>
<div class="code-sample" style="margin-top:10px;font-size:12px;"># C / C++ — gcov + lcov (LCOV)
lcov --capture --directory . --output-file coverage/lcov.info
# C / C++ — llvm-cov (LCOV)
llvm-profdata merge -sparse default.profraw -o default.profdata
llvm-cov export -format=lcov -instr-profile=default.profdata ./mybinary > coverage/lcov.info
# C# — coverlet (Cobertura XML)
dotnet test --collect:"XPlat Code Coverage"
# Python — pytest-cov (Cobertura XML)
pytest --cov --cov-report=xml
# Java / Kotlin — Gradle + JaCoCo (JaCoCo XML)
./gradlew jacocoTestReport</div>
</div>
</div>
</div>
<div class="wizard-actions">
<div class="left"></div>
<div class="right">
<button type="button" class="secondary next-step" data-next="2">Next: Counting rules</button>
</div>
</div>
</div>
<div class="wizard-step" data-step="2">
<div class="section">
<div class="section-kicker">Step 2</div>
<h2>Choose counting behavior</h2>
<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>
<div class="subsection-bar">Primary line classification</div>
<div class="preset-kv-row">
<div class="toggle-card mixed-line-card" style="margin:0;">
<div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
<h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
<select id="mixed_line_policy" name="mixed_line_policy">
<option value="code_only">Code only</option>
<option value="code_and_comment">Code and comment</option>
<option value="comment_only">Comment only</option>
<option value="separate_mixed_category">Separate mixed category</option>
</select>
<div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
</div>
<div class="explainer-card prominent" style="margin:0;">
<div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
<div class="explainer-body" id="mixed-policy-description"></div>
<div class="code-sample" id="mixed-policy-example"></div>
</div>
</div>
</div>
<div class="subsection-bar">Additional scan rules</div>
<div class="scan-rules-grid">
<div class="preset-inline-row">
<div class="toggle-card" style="margin:0;">
<div class="field-help-title">Generated files</div>
<h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
<select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
</div>
<div class="explainer-card prominent" style="margin:0;">
<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>
<div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
# Files matching codegen patterns are excluded:
# *.generated.cs *.pb.go *.g.dart</div>
</div>
</div>
<div class="preset-inline-row">
<div class="toggle-card" style="margin:0;">
<div class="field-help-title">Minified files</div>
<h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
<select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
</div>
<div class="explainer-card prominent" style="margin:0;">
<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>
<div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
# Heuristic: very long lines + low whitespace ratio
# jquery.min.js bundle.min.css → skipped</div>
</div>
</div>
<div class="preset-inline-row">
<div class="toggle-card" style="margin:0;">
<div class="field-help-title">Vendor directories</div>
<h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
<select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
</div>
<div class="explainer-card prominent" style="margin:0;">
<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>
<div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
# Directories named vendor/ node_modules/ third_party/
# → entire subtree is excluded from totals</div>
</div>
</div>
<div class="preset-inline-row">
<div class="toggle-card" style="margin:0;">
<div class="field-help-title">Lockfiles and manifests</div>
<h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
<select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
</div>
<div class="explainer-card prominent" style="margin:0;">
<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>
<div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false (default)
# Files like package-lock.json Cargo.lock yarn.lock
# → skipped unless this is enabled</div>
</div>
</div>
<div class="preset-inline-row">
<div class="toggle-card" style="margin:0;">
<div class="field-help-title">Binary handling</div>
<h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
<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>
</div>
<div class="explainer-card prominent" style="margin:0;">
<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>
<div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip" (default)
# Detected via long lines + low whitespace heuristic
# .png .exe .so → skipped silently</div>
</div>
</div>
<div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
<div class="toggle-card" style="margin:0;">
<div class="field-help-title">Python docstrings</div>
<h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
<label class="checkbox">
<input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
<span>Count as comment-style lines</span>
</label>
</div>
<div class="explainer-card prominent" style="margin:0;">
<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>
<div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
</div>
</div>
</div>
<div class="subsection-bar">IEEE 1045-1992 counting</div>
<div class="scan-rules-grid">
<div class="preset-inline-row">
<div class="toggle-card" style="margin:0;">
<div class="field-help-title">Continuation lines</div>
<h4 style="margin:6px 0 12px;font-size:16px;">Continuation-line policy</h4>
<select name="continuation_line_policy" id="continuation_line_policy">
<option value="each_physical_line" selected>Each physical line (default)</option>
<option value="collapse_to_logical">Collapse to logical line</option>
</select>
</div>
<div class="explainer-card prominent" style="margin:0;">
<div class="advanced-rule-description"><strong>Purpose:</strong> Controls how backslash-continued lines (C macros, shell, Makefile) are counted.<br /><strong>Each physical line</strong> — the IEEE 1045-1992 default; every line with content is counted separately.<br /><strong>Collapse to logical</strong> — a backslash-continued sequence counts as one logical line, matching logical-SLOC conventions.</div>
<div class="code-sample" style="margin-top:10px;font-size:12px;">#define MAX(a, b) \
((a) > (b) ? (a) : (b))
# each_physical_line → 2 SLOC
# collapse_to_logical → 1 SLOC</div>
</div>
</div>
<div class="preset-inline-row">
<div class="toggle-card" style="margin:0;">
<div class="field-help-title">Block-comment blanks</div>
<h4 style="margin:6px 0 12px;font-size:16px;">Blank lines in block comments</h4>
<select name="blank_in_block_comment_policy" id="blank_in_block_comment_policy">
<option value="count_as_comment" selected>Count as comment (default)</option>
<option value="count_as_blank">Count as blank</option>
</select>
</div>
<div class="explainer-card prominent" style="margin:0;">
<div class="advanced-rule-description"><strong>Purpose:</strong> Decides how blank lines that fall inside a <code style="font-size:12px;">/* … */</code> block comment are classified.<br /><strong>Count as comment</strong> — IEEE-aligned; blank lines are part of the comment body.<br /><strong>Count as blank</strong> — legacy behaviour; blank lines inside block comments are treated as ordinary blank lines.</div>
<div class="code-sample" style="margin-top:10px;font-size:12px;">/*
* Summary line
* ← blank inside block comment
* Detail line
*/
# count_as_comment → blank counts toward comments
# count_as_blank → blank counts toward blanks</div>
</div>
</div>
<div class="preset-inline-row">
<div class="toggle-card" style="margin:0;">
<div class="field-help-title">Compiler directives</div>
<h4 style="margin:6px 0 12px;font-size:16px;">Count compiler directives</h4>
<select name="count_compiler_directives" id="count_compiler_directives">
<option value="enabled" selected>Include in code SLOC (default)</option>
<option value="disabled">Exclude from code SLOC</option>
</select>
</div>
<div class="explainer-card prominent" style="margin:0;">
<div class="advanced-rule-description"><strong>Purpose:</strong> IEEE 1045-1992 §4.2 — controls whether preprocessor directives contribute to code SLOC. Applies to C, C++, and Objective-C.<br /><strong>Include</strong> — <code style="font-size:12px;">#include</code> / <code style="font-size:12px;">#define</code> lines count toward code SLOC (default).<br /><strong>Exclude</strong> — directives are tracked separately in raw counts but not added to effective code SLOC; useful when comparing with tools that strip the preprocessor layer.</div>
<div class="code-sample" style="margin-top:10px;font-size:12px;">#include <stdio.h> ← compiler directive
#define BUF 256 ← compiler directive
int main() { … } ← code
# enabled → 3 code SLOC
# disabled → 1 code SLOC + 2 directive lines</div>
</div>
</div>
</div>
<div class="subsection-bar">Code Style Analysis</div>
<div class="scan-rules-grid">
<div class="preset-inline-row">
<div class="toggle-card" style="margin:0;">
<div class="field-help-title">Style analysis</div>
<h4 style="margin:6px 0 12px;font-size:16px;">Enable style analysis</h4>
<select name="style_analysis_enabled" id="style_analysis_enabled">
<option value="enabled" selected>Enabled (default)</option>
<option value="disabled">Disabled — skip style scoring</option>
</select>
</div>
<div class="explainer-card prominent" style="margin:0;">
<div class="advanced-rule-description"><strong>Purpose:</strong> Controls whether lexical style-guide heuristics run at all.<br /><strong>Enable</strong> — every supported file is scored against its language's style guides and the results appear in the report (default).<br /><strong>Disable</strong> — style scoring is skipped entirely; useful for very large repos where you only need SLOC counts.</div>
<div class="code-sample" style="margin-top:10px;font-size:12px;"># style_analysis_enabled = true (default)
# style_analysis_enabled = false (skip, faster scan)
# Disabling removes the Code Style section from the report.</div>
</div>
</div>
<div class="preset-inline-row">
<div class="toggle-card" style="margin:0;">
<div class="field-help-title">Column-width threshold</div>
<h4 style="margin:6px 0 12px;font-size:16px;">Line-length compliance column</h4>
<select name="style_col_threshold" id="style_col_threshold">
<option value="80" selected>80 columns (PEP 8, Google, gofmt)</option>
<option value="100">100 columns (Uber Go, Google Java)</option>
<option value="120">120 columns (Uber Go max, Kotlin)</option>
</select>
</div>
<div class="explainer-card prominent" style="margin:0;">
<div class="advanced-rule-description"><strong>Purpose:</strong> Sets the column width used to compute the <em>N-col Compliant</em> summary chip in the Code Style Analysis section of the report.<br /><strong>A file is compliant</strong> when ≤ 5 % of its lines exceed this limit.<br /><strong>Does not affect SLOC counts</strong> — only the style-adherence reporting. The style guide scores themselves are always computed across all three thresholds (80 / 100 / 120) regardless of this setting.</div>
<div class="code-sample" style="margin-top:10px;font-size:12px;"># style_col_threshold = 80 (PEP 8, Google, gofmt)
# style_col_threshold = 100 (Uber Go, Google Java)
# style_col_threshold = 120 (Uber Go max, Kotlin)
# Files where <= 5% of lines exceed the limit
# are counted as "N-col compliant" in the report.</div>
</div>
</div>
<div class="preset-inline-row">
<div class="toggle-card" style="margin:0;">
<div class="field-help-title">Score alert threshold</div>
<h4 style="margin:6px 0 12px;font-size:16px;">Low-score file alert</h4>
<select name="style_score_threshold" id="style_score_threshold">
<option value="0" selected>Off — no threshold (default)</option>
<option value="40">40% — flag poorly styled files</option>
<option value="50">50% — flag below-average files</option>
<option value="60">60% — flag below-good files</option>
<option value="70">70% — flag below-strong files</option>
</select>
</div>
<div class="explainer-card prominent" style="margin:0;">
<div class="advanced-rule-description"><strong>Purpose:</strong> Files whose dominant-guide adherence score falls below this percentage are highlighted with a red left-border in the per-file style table — making it easy to spot the lowest-conformance files at a glance.<br /><strong>Off</strong> — all files shown without any alert (default).<br /><strong>Any other value</strong> — a red indicator flags each file scoring below the threshold.</div>
<div class="code-sample" style="margin-top:10px;font-size:12px;"># style_score_threshold = 0 (off, default)
# style_score_threshold = 50 (flag files < 50%)
# Low-scoring files get a red left-border in the
# per-file style breakdown table.</div>
</div>
</div>
</div>
<div class="always-tracked-tip">
<div class="always-tracked-tip-icon">ℹ</div>
<div class="always-tracked-tip-body">
<div class="field-help-title">Always tracked — not configurable · What these settings change</div>
<h4>Comment and blank-line basics & Lines on the boundary</h4>
<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>
</div>
</div>
<div class="subsection-bar">Advanced Metrics</div>
<div class="scan-rules-grid">
<div class="preset-inline-row">
<div class="toggle-card" style="margin:0;">
<div class="field-help-title">COCOMO mode</div>
<h4 style="margin:6px 0 12px;font-size:16px;">Cost estimation model</h4>
<select name="cocomo_mode" id="cocomo_mode">
<option value="organic" selected>Organic — small team, familiar domain (default)</option>
<option value="semi_detached">Semi-detached — mixed constraints</option>
<option value="embedded">Embedded — tight hardware/OS constraints</option>
</select>
</div>
<div class="explainer-card prominent" style="margin:0;">
<div class="advanced-rule-description"><strong>Purpose:</strong> Selects the COCOMO I Basic mode used to estimate development effort, schedule, and team size from code SLOC.<br /><strong>Organic</strong> — small teams with good experience on similar problems (most software projects).<br /><strong>Semi-detached</strong> — mixed experience; some novel aspects; medium-sized projects.<br /><strong>Embedded</strong> — tight hardware, OS, or real-time constraints; high innovation; large projects.</div>
<div class="code-sample" style="margin-top:10px;font-size:12px;"># Organic: Effort = 2.4 × KSLOC^1.05
# Semi-detached: Effort = 3.0 × KSLOC^1.12
# Embedded: Effort = 3.6 × KSLOC^1.20
# All modes: Schedule = 2.5 × Effort^d</div>
</div>
</div>
<div class="preset-inline-row">
<div class="toggle-card" style="margin:0;">
<div class="field-help-title">Complexity alert</div>
<h4 style="margin:6px 0 12px;font-size:16px;">Complexity score alert threshold</h4>
<input type="number" name="complexity_alert" id="complexity_alert" min="0" max="9999" placeholder="e.g. 100 — leave blank for no alert" style="width:100%;padding:8px 12px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:var(--text);font-size:14px;" />
</div>
<div class="explainer-card prominent" style="margin:0;">
<div class="advanced-rule-description"><strong>Purpose:</strong> When set, files whose total cyclomatic complexity score exceeds this threshold are highlighted in the results page with an accent border.<br /><strong>Complexity score</strong> counts branch decision keywords (if, for, while, ||, &&, …) across all code lines — a fast lexical approximation of McCabe complexity.<br /><strong>Common thresholds:</strong> 50 for a simple project, 100–200 for medium, 300+ for large repos.</div>
<div class="code-sample" style="margin-top:10px;font-size:12px;"># 0 or blank = no alert (default)
# 50 = flag any file with > 50 branch points
# 100 = flag any file with > 100 branch points
# Files above the threshold are highlighted
# in the result page metric strip.</div>
</div>
</div>
<div class="preset-inline-row">
<div class="toggle-card" style="margin:0;">
<div class="field-help-title">Duplicate handling</div>
<h4 style="margin:6px 0 12px;font-size:16px;">Duplicate file detection</h4>
<select name="exclude_duplicates" id="exclude_duplicates">
<option value="disabled" selected>Detect and report only (default)</option>
<option value="enabled">Detect and exclude from SLOC totals</option>
</select>
</div>
<div class="explainer-card prominent" style="margin:0;">
<div class="advanced-rule-description"><strong>Purpose:</strong> Detects files with identical content (bit-for-bit copies) that would otherwise inflate SLOC counts.<br /><strong>Detect and report only</strong> — duplicates are counted normally in totals; a "Duplicate groups" chip in the result page shows how many groups exist (default).<br /><strong>Detect and exclude</strong> — only one file per identical-content group contributes to code/comment/blank line totals; the rest are silently excluded.</div>
<div class="code-sample" style="margin-top:10px;font-size:12px;"># A repo with 3 identical config files:
# detect only → all 3 counted in SLOC
# exclude dupes → 1 counted, 2 excluded
# Duplicate groups chip always shows the count.</div>
</div>
</div>
<div class="always-tracked-tip" style="margin:8px 0 0;">
<div class="always-tracked-tip-icon">ℹ</div>
<div class="always-tracked-tip-body">
<div class="field-help-title">Always computed — every scan produces these automatically</div>
<div class="always-tracked-metrics-row">
<div><strong>Cyclomatic complexity</strong>Counts branch keywords per file.</div>
<div><strong>Logical SLOC</strong>Executable statements — C-family, Python, Ruby, Shell & more.</div>
<div><strong>ULOC & DRYness</strong>De-duplicates lines project-wide; DRYness % = ULOC ÷ Code Lines.</div>
<div><strong>COCOMO I</strong>Converts total SLOC into effort, schedule & team-size estimates.</div>
</div>
<div class="hint" style="margin-top:8px;">All four appear in the results page. The settings above only affect how they are displayed or whether edge cases are excluded.</div>
</div>
</div>
</div>
<div class="wizard-actions">
<div class="left">
<button type="button" class="secondary prev-step" data-prev="1">Back</button>
</div>
<div class="right">
<button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
</div>
</div>
</div>
<div class="wizard-step" data-step="3">
<div class="section">
<div class="section-kicker">Step 3</div>
<h2>Output and report identity</h2>
<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>
<div class="preset-kv-row">
<div class="toggle-card" style="margin:0;">
<div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
<h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
<select id="scan_preset">
<option value="balanced">Balanced local scan</option>
<option value="code_focused">Code focused</option>
<option value="comment_audit">Comment audit</option>
<option value="deep_review">Deep review</option>
</select>
<div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
</div>
<div class="explainer-card">
<div class="field-help-title">Selected scan preset</div>
<div class="explainer-body" id="scan-preset-description"></div>
<div class="preset-summary-row" id="scan-preset-summary"></div>
<div class="code-sample" id="scan-preset-example"></div>
<div class="preset-note" id="scan-preset-note"></div>
</div>
</div>
<hr class="step3-separator" />
<div class="preset-kv-row">
<div class="toggle-card" style="margin:0;">
<div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
<h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
<select id="artifact_preset">
<option value="review">Review bundle</option>
<option value="full">Full bundle</option>
<option value="html_only">HTML only</option>
<option value="machine">Machine bundle</option>
</select>
<div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
</div>
<div class="explainer-card">
<div class="field-help-title">Selected artifact preset</div>
<div class="explainer-body" id="artifact-preset-description"></div>
<div class="preset-summary-row" id="artifact-preset-summary"></div>
<div class="code-sample" id="artifact-preset-example"></div>
</div>
</div>
</div>
<div class="section section-spacer-top">
<div class="output-field-row">
<div class="field">
<label for="output_dir">Output directory</label>
{% if server_mode %}
<div class="input-group compact">
<input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" readonly style="cursor:default;opacity:0.68;background:var(--surface-2);" />
</div>
<div class="hint">Output path is managed by the server — each run stores artifacts in a unique timestamped subfolder automatically.</div>
{% else %}
<div class="input-group compact">
<input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" />
<button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
<button type="button" class="mini-button" id="use-default-output">Use default</button>
</div>
<div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
{% endif %}
</div>
<div class="output-field-aside">
<strong>Where reports land</strong>
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.
</div>
</div>
</div>
<div class="section section-spacer-top">
<div class="output-field-row">
<div class="field">
<label for="report_title">Report title</label>
<input id="report_title" name="report_title" type="text" value="" placeholder="Project report title" />
<div class="hint">Appears in HTML and PDF output headers.</div>
</div>
<div class="output-field-aside">
<strong>Shown in exported artifacts</strong>
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.
</div>
</div>
</div>
<div class="section section-spacer-top">
<div class="output-field-row">
<div class="field">
<label for="report_header_footer">Report header / footer</label>
<input id="report_header_footer" name="report_header_footer" type="text" value="" placeholder="e.g. Acme Corp — Confidential · Project Athena" />
<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>
</div>
<div class="output-field-aside">
<strong>Page-level identification</strong>
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.
</div>
</div>
</div>
<div class="wizard-actions">
<div class="left">
<button type="button" class="secondary prev-step" data-prev="2">Back</button>
</div>
<div class="right">
<button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
</div>
</div>
</div>
<div class="wizard-step" data-step="4">
<div class="section">
<div class="section-kicker">Step 4</div>
<h2>Review selections and run</h2>
<p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
<div class="review-grid">
<div class="review-card highlight">
<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>
<ul id="review-scan-summary"></ul>
</div>
<div class="review-card highlight">
<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>
<ul id="review-count-summary"></ul>
</div>
<div class="review-card">
<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>
<ul id="review-artifact-summary"></ul>
<ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
</div>
<div class="review-card">
<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>
<ul id="review-preview-summary"></ul>
</div>
</div>
</div>
<div class="wizard-actions">
<div class="left">
<button type="button" class="secondary prev-step" data-prev="3">Back</button>
</div>
<div class="right">
<button type="submit" id="submit-button" class="primary">Run analysis</button>
</div>
</div>
</div>
{% if server_mode %}
<input type="file" id="dir-upload-input" webkitdirectory multiple style="display:none" aria-hidden="true">
<input type="file" id="cov-upload-input" accept=".info,.lcov,.xml" style="display:none" aria-hidden="true">
{% endif %}
</form>
</div>
</section>
</div>
</div>
<script nonce="{{ csp_nonce }}">
(function () {
function startScanPhase() {
var phaseEl = document.getElementById("scan-phase");
if (!phaseEl) return;
var phases = [
"Discovering files...",
"Decoding file encodings...",
"Detecting languages...",
"Analyzing source lines...",
"Applying counting policies...",
"Aggregating results...",
"Rendering report..."
];
var durations = [800, 600, 1200, 3000, 1000, 800, 600];
var i = 0;
function next() {
phaseEl.style.opacity = "0";
setTimeout(function () {
phaseEl.textContent = phases[i];
phaseEl.style.opacity = "0.85";
var delay = durations[i] || 1800;
i++;
if (i < phases.length) { setTimeout(next, delay); }
}, 200);
}
next();
}
var form = document.getElementById("analyze-form");
var loading = document.getElementById("loading");
var submitButton = document.getElementById("submit-button");
var pathInput = document.getElementById("path");
var GIT_MODE = !!(pathInput && pathInput.readOnly);
var GIT_LABEL = GIT_MODE ? {{ git_label_json|safe }} : "";
var GIT_OUTPUT_DIR = GIT_MODE ? {{ git_output_dir_json|safe }} : "";
var outputDirInput = document.getElementById("output_dir");
var reportTitleInput = document.getElementById("report_title");
var previewPanel = document.getElementById("preview-panel");
var refreshButton = document.getElementById("refresh-preview");
var refreshPreviewInline = document.getElementById("refresh-preview-inline");
var useSamplePath = document.getElementById("use-sample-path");
var useDefaultOutput = document.getElementById("use-default-output");
var browsePath = document.getElementById("browse-path");
var browseOutputDir = document.getElementById("browse-output-dir");
var browseCoverage = document.getElementById("browse-coverage");
var coverageInput = document.getElementById("coverage_file");
var covScanStatus = document.getElementById("cov-scan-status");
var coverageSuggestTimer = null;
var covAutoFilled = false;
var SERVER_MODE = {% if server_mode %}true{% else %}false{% endif %};
// Scroll long path inputs to end on blur (replaces inline onblur="..." removed for CSP).
(function() {
var ids = ["path", "output_dir"];
ids.forEach(function(id) {
var el = document.getElementById(id);
if (el) el.addEventListener("blur", function() { this.scrollLeft = this.scrollWidth; });
});
}());
function fmtBytes(b) {
b = Number(b) || 0;
if (b >= 1073741824) return (b / 1073741824).toFixed(1).replace(/\.0$/, '') + ' GB';
if (b >= 1048576) return (b / 1048576).toFixed(1).replace(/\.0$/, '') + ' MB';
if (b >= 1024) return Math.round(b / 1024) + ' KB';
return b + ' B';
}
var themeToggle = document.getElementById("theme-toggle");
function showBannerToast(msg, isError, opts) {
opts = opts || {};
var t = document.createElement('div');
t.className = isError ? 'toast-error' : 'toast-success';
var topPos = opts.top ? '80px' : null;
t.style.cssText = 'position:fixed;' + (topPos ? 'top:' + topPos + ';' : 'bottom:24px;') +
'left:50%;transform:translateX(-50%);z-index:9999;min-width:320px;max-width:560px;' +
'box-shadow:0 8px 32px rgba(0,0,0,0.22);padding:14px 20px;border-radius:12px;' +
'font-size:13px;font-weight:600;line-height:1.5;text-align:center;';
if (opts.icon) {
var inner = document.createElement('span');
inner.innerHTML = opts.icon + ' ';
t.appendChild(inner);
}
t.appendChild(document.createTextNode(msg));
document.body.appendChild(t);
setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 5500);
}
var mixedLinePolicy = document.getElementById("mixed_line_policy");
var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
var scanPreset = document.getElementById("scan_preset");
var artifactPreset = document.getElementById("artifact_preset");
var includeGlobsInput = document.getElementById("include_globs");
var excludeGlobsInput = document.getElementById("exclude_globs");
// Include globs scope badge — updates reactively as the user types.
(function() {
var badge = document.getElementById("include-scope-badge");
if (!badge || !includeGlobsInput) return;
var iconCheck = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg> ';
var iconFilter = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon></svg> ';
function update() {
var val = includeGlobsInput.value.trim();
if (!val) {
badge.className = "include-scope-badge scope-all";
badge.innerHTML = iconCheck + "All files eligible — no include filter active";
} else {
var count = val.split(/[\n,]+/).filter(function(s) { return s.trim(); }).length;
badge.className = "include-scope-badge scope-narrow";
badge.innerHTML = iconFilter + "Scoped to " + count + " pattern" + (count === 1 ? "" : "s") + " — only matching files will be included";
}
}
includeGlobsInput.addEventListener("input", update);
update();
}());
// Quick-exclude chips — append pattern to exclude_globs textarea.
document.querySelectorAll(".quick-excl-chip").forEach(function(chip) {
chip.addEventListener("click", function() {
var pattern = chip.getAttribute("data-pattern") || "";
if (!pattern || !excludeGlobsInput) return;
var current = excludeGlobsInput.value.trim();
// For the "skip all" chip, replace any existing dep patterns cleanly.
var patterns = pattern.split("\n");
var lines = current ? current.split("\n").map(function(l) { return l.trim(); }).filter(Boolean) : [];
var added = false;
patterns.forEach(function(p) {
p = p.trim();
if (p && lines.indexOf(p) === -1) { lines.push(p); added = true; }
});
if (added) {
excludeGlobsInput.value = lines.join("\n");
excludeGlobsInput.dispatchEvent(new Event("input"));
}
chip.classList.add("active");
});
});
var liveReportTitle = document.getElementById("live-report-title");
var navProjectPill = document.getElementById("nav-project-pill");
var navProjectTitle = document.getElementById("nav-project-title");
var reportTitlePreview = null;
var wizardProgressFill = document.getElementById("wizard-progress-fill");
var wizardProgressValue = document.getElementById("wizard-progress-value");
var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
var reportTitleTouched = false;
var currentStep = 1;
var previewTimer = null;
var _previewGen = 0;
var quickScanBtn = document.getElementById("quick-scan-btn");
function dismissAnalysisModal() {
if (loading) loading.classList.remove("active");
["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
var el = document.getElementById(id);
if (el) el.classList.add("hidden");
});
var cancelBtn = document.getElementById("lc-cancel-btn");
if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; cancelBtn.textContent = "✕ Cancel scan"; }
var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
var sd = document.getElementById("lc-stage-desc"); if (sd) sd.textContent = "Initializing language analyzers and loading configuration…";
for (var ri=1;ri<=4;ri++){var rs=document.getElementById("lc-step-"+ri);if(!rs)continue;rs.classList.remove("active","done");if(ri===1)rs.classList.add("active");}
var rsc=document.getElementById("lc-speed-card");if(rsc)rsc.classList.add("hidden");
var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
}
var lcDismissBtn = document.getElementById("lc-dismiss");
if (lcDismissBtn) lcDismissBtn.addEventListener("click", dismissAnalysisModal);
// When the browser restores this page from bfcache (Back button after navigating to results),
// the loading overlay would still be showing its active state. Dismiss it immediately.
window.addEventListener("pageshow", function(e) {
if (e.persisted) { dismissAnalysisModal(); }
});
function startAsyncAnalysis(formData) {
var gitRepo = (formData.get("git_repo") || "").toString();
var gitRef = (formData.get("git_ref") || "").toString();
var pathVal = (gitRepo || (formData.get("path") || "")).toString();
var displayPath = (gitRepo && gitRef) ? pathVal + " @ " + gitRef : pathVal;
var pathEl = document.getElementById("lc-path-text");
if (pathEl) pathEl.textContent = displayPath;
["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
var el = document.getElementById(id);
if (el) el.classList.add("hidden");
});
var cancelBtn = document.getElementById("lc-cancel-btn");
if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; }
var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "";
var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
var elapsed0 = document.getElementById("lc-elapsed"); if (elapsed0) elapsed0.textContent = "0s";
var phase0 = document.getElementById("lc-phase"); if (phase0) phase0.textContent = "Starting";
var sd0 = document.getElementById("lc-stage-desc"); if (sd0) sd0.textContent = "Initializing language analyzers and loading configuration…";
for (var si=1;si<=4;si++){var ss=document.getElementById("lc-step-"+si);if(!ss)continue;ss.classList.remove("active","done");if(si===1)ss.classList.add("active");}
var sc0=document.getElementById("lc-speed-card");if(sc0)sc0.classList.add("hidden");
if (loading) loading.classList.add("active");
var startTime = Date.now();
var elapsedTimer = setInterval(function() {
var s = Math.floor((Date.now() - startTime) / 1000);
var el = document.getElementById("lc-elapsed");
if (el) el.textContent = s < 60 ? s + "s" : Math.floor(s/60) + "m " + (s%60) + "s";
}, 1000);
var warnShown = false, pollRetries = 0, activeWaitId = null, lastFd = 0, lastFdTime = Date.now();
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(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}
var PHASE_DESC = {
'Starting': 'Initializing language analyzers and loading configuration…',
'Scanning files': 'Walking the directory tree, applying scope filters, and reading file bytes…',
'Running': 'Running the lexical state machine across all discovered source files…',
'Writing reports': 'Rendering the HTML report and saving JSON artifacts to disk…',
'Done': 'Analysis complete — loading your results…',
'Failed': 'Analysis encountered an error. Check the path and permissions, then try again.'
};
var PHASE_STEP = {'Starting':1,'Scanning files':1,'Running':2,'Writing reports':3,'Done':4};
function lcSetPhase(txt) {
var el = document.getElementById("lc-phase"); if (el) el.textContent = txt;
var desc = document.getElementById("lc-stage-desc");
if (desc) desc.textContent = PHASE_DESC[txt] || (txt + '…');
var step = PHASE_STEP[txt] || 1;
for (var i=1;i<=4;i++){var s=document.getElementById("lc-step-"+i);if(!s)continue;s.classList.remove("active","done");if(i<step)s.classList.add("done");else if(i===step)s.classList.add("active");}
}
function lcShowCancelled() {
clearInterval(elapsedTimer);
var badge = document.getElementById("lc-badge"); if (badge) badge.style.display = "none";
var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "none";
var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "none";
var warnEl = document.getElementById("lc-warn"); if (warnEl) warnEl.classList.add("hidden");
var cancelledEl = document.getElementById("lc-cancelled"); if (cancelledEl) cancelledEl.classList.remove("hidden");
var actEl = document.getElementById("lc-actions"); if (actEl) actEl.classList.remove("hidden");
var cancelBtn = document.getElementById("lc-cancel-btn"); if (cancelBtn) cancelBtn.style.display = "none";
var titleEl = document.getElementById("lc-title"); if (titleEl) titleEl.textContent = "Scan cancelled";
if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
}
var lcCancelBtn = document.getElementById("lc-cancel-btn");
if (lcCancelBtn) {
lcCancelBtn.onclick = function() {
if (!activeWaitId) { dismissAnalysisModal(); return; }
lcCancelBtn.disabled = true;
lcCancelBtn.textContent = "Cancelling…";
fetch("/api/runs/" + encodeURIComponent(activeWaitId) + "/cancel", { method: "POST" })
.then(function() { lcShowCancelled(); })
.catch(function() { lcShowCancelled(); });
};
}
function lcShowError(msg) {
clearInterval(elapsedTimer);
lcSetPhase("Failed");
var msgEl = document.getElementById("lc-err-msg");
if (msgEl) msgEl.textContent = msg || "Analysis failed.";
var errEl = document.getElementById("lc-err");
var actEl = document.getElementById("lc-actions");
if (errEl) errEl.classList.remove("hidden");
if (actEl) actEl.classList.remove("hidden");
if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
}
function lcPoll(waitId) {
fetch("/api/runs/" + encodeURIComponent(waitId) + "/status")
.then(function(r) {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
})
.then(function(data) {
pollRetries = 0;
if (data.state === "complete") {
clearInterval(elapsedTimer);
lcSetPhase("Done");
window.location.href = "/runs/result/" + encodeURIComponent(data.run_id);
} else if (data.state === "failed") {
lcShowError(data.message);
} else if (data.state === "cancelled") {
lcShowCancelled();
} else {
var s = Math.floor((Date.now() - startTime) / 1000);
if (s > 90 && !warnShown) {
warnShown = true;
var w = document.getElementById("lc-warn");
if (w) w.classList.remove("hidden");
}
lcSetPhase(data.phase || "Running");
var fd = data.files_done || 0, ft = data.files_total || 0;
if (ft > 0) {
var card = document.getElementById("lc-files-card");
if (card) card.classList.remove("hidden");
var el = document.getElementById("lc-files");
if (el) el.textContent = fmt(fd) + " / " + fmt(ft);
var now = Date.now();
var fdelta = fd - lastFd, tdelta = (now - lastFdTime) / 1000;
if (fdelta > 0 && tdelta > 0.4) {
var fps = Math.round(fdelta / tdelta);
var spEl = document.getElementById("lc-speed"); if (spEl) spEl.textContent = fmt(fps);
var spCard = document.getElementById("lc-speed-card"); if (spCard) spCard.classList.remove("hidden");
}
lastFd = fd; lastFdTime = now;
}
setTimeout(function() { lcPoll(waitId); }, 1500);
}
})
.catch(function() {
pollRetries++;
if (pollRetries >= 5) {
lcShowError("Lost connection to server. Reload to check status.");
} else {
setTimeout(function() { lcPoll(waitId); }, Math.min(1500 * Math.pow(2, pollRetries), 8000));
}
});
}
var params = new URLSearchParams(formData);
fetch("/analyze", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" } })
.then(function(r) {
var waitId = r.headers.get("x-wait-id");
if (!waitId) { window.location.href = "/scan"; return; }
activeWaitId = waitId;
setTimeout(function() { lcPoll(waitId); }, 1500);
})
.catch(function(err) {
lcShowError("Could not reach server: " + (err.message || err));
});
}
if (quickScanBtn) {
quickScanBtn.addEventListener("click", function () {
var pathVal = pathInput ? pathInput.value.trim() : "";
if (!pathVal) {
alert("Please enter or browse to a project path first.");
return;
}
quickScanBtn.disabled = true;
quickScanBtn.textContent = "Scanning...";
if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
startAsyncAnalysis(new FormData(form));
});
}
var mixedPolicyInfo = {
code_only: {
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.",
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'
},
code_and_comment: {
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.",
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'
},
comment_only: {
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.",
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'
},
separate_mixed_category: {
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.",
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'
}
};
var scanPresetInfo = {
balanced: {
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.",
chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
note: "Best when you want a stable local overview before making deeper adjustments.",
apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
},
code_focused: {
description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
note: "Use this when you mainly care about implementation size and want cleaner code totals.",
apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
},
comment_audit: {
description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
},
deep_review: {
description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
}
};
var artifactPresetInfo = {
review: {
description: "HTML report for in-browser review. No PDF or data exports — fast and lightweight.",
chips: ["HTML", "no PDF", "no JSON/CSV/XLSX"],
example: "Ideal for a quick local review before sharing results."
},
full: {
description: "All artifacts: HTML, PDF, JSON, CSV, and XLSX. Best for handoff packages or archiving.",
chips: ["HTML", "PDF", "JSON", "CSV", "XLSX"],
example: "Use when producing a deliverable or storing a snapshot for future comparison."
},
html_only: {
description: "Standalone HTML report only. No PDF generation, no data files.",
chips: ["HTML only"],
example: "Fastest option when you only need to open the report in a browser."
},
machine: {
description: "JSON and CSV data files only — no HTML or PDF. Designed for CI pipelines and automation.",
chips: ["JSON", "CSV", "no HTML", "no PDF"],
example: "Use in CI to capture metrics without generating visual reports."
}
};
function applyArtifactPreset() {
var info = artifactPresetInfo[artifactPreset ? artifactPreset.value : "review"];
if (!info) return;
var descEl = document.getElementById("artifact-preset-description");
var exampleEl = document.getElementById("artifact-preset-example");
if (descEl) descEl.textContent = info.description;
if (exampleEl) exampleEl.textContent = info.example;
renderPresetChips("artifact-preset-summary", info.chips);
}
function applyTheme(theme) {
if (theme === "dark") document.body.classList.add("dark-theme");
else document.body.classList.remove("dark-theme");
}
function loadSavedTheme() {
var saved = null;
try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
applyTheme(saved === "dark" ? "dark" : "light");
}
function updateScrollProgress() {
// Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
// Within each step, scroll position nudges the bar forward (max just below the next milestone).
var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1–4 (index = step number)
var stepEnd = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
var step = Math.min(Math.max(currentStep, 1), 4);
var base = stepBase[step];
var end = stepEnd[step];
var scrollFrac = 0;
var activePanel = document.querySelector(".wizard-step.active");
if (activePanel) {
var scrollTop = window.scrollY || window.pageYOffset || 0;
var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
var scrolled = scrollTop + viewH - panelTop;
scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
}
var percent = Math.round(base + (end - base) * scrollFrac);
percent = Math.min(end, Math.max(base, percent));
if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
}
function updateWizardProgress() {
updateScrollProgress();
}
var stepDescriptions = [
"Choose a project folder, apply scope filters, and preview which files will be counted.",
"Configure how mixed code-plus-comment lines and docstrings are classified.",
"Pick your output formats, scan preset, and where reports are saved.",
"Review all settings and launch the analysis."
];
function updateStepNav(step) {
var infoLabel = document.getElementById("step-nav-info-label");
var infoDesc = document.getElementById("step-nav-info-desc");
if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
if (infoDesc) infoDesc.textContent = stepDescriptions[step - 1] || "";
}
function updateSidebarSummary() {
var sumPath = document.getElementById("sum-path");
var sumPreset = document.getElementById("sum-preset");
var sumOutput = document.getElementById("sum-output");
var sidebarSummary = document.getElementById("sidebar-summary");
var pathVal = (pathInput && pathInput.value.trim()) ? inferTitleFromPath(pathInput.value) : "";
var presetVal = (scanPreset && scanPreset.value) ? scanPreset.value.replace(/_/g, " ") : "";
var outputVal = (artifactPreset && artifactPreset.value) ? artifactPreset.value.replace(/_/g, " ") : "";
if (sumPath) sumPath.textContent = pathVal || "—";
if (sumPreset) sumPreset.textContent = presetVal || "—";
if (sumOutput) sumOutput.textContent = outputVal || "—";
if (sidebarSummary) sidebarSummary.style.display = (pathVal || presetVal || outputVal) ? "" : "none";
}
function setStep(step, pushHistory) {
currentStep = step;
stepPanels.forEach(function (panel) {
panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
});
stepButtons.forEach(function (button) {
button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
});
var layoutEl = document.querySelector(".layout");
if (layoutEl) layoutEl.setAttribute("data-active-step", step);
updateWizardProgress();
updateStepNav(step);
stepButtons.forEach(function(btn) {
var t = Number(btn.getAttribute("data-step-target"));
btn.classList.toggle("done", t < step);
});
updateSidebarSummary();
if (pushHistory !== false) {
try {
history.pushState({ wizardStep: step }, "", "#step" + step);
} catch (e) {}
}
window.scrollTo({ top: 0, behavior: "instant" });
}
window.addEventListener("popstate", function (e) {
if (e.state && e.state.wizardStep) {
setStep(e.state.wizardStep, false);
} else {
var hashMatch = location.hash.match(/^#step([1-4])$/);
if (hashMatch) setStep(Number(hashMatch[1]), false);
}
});
function inferTitleFromPath(value) {
if (!value) return "project";
var cleaned = value.replace(/[\/\\]+$/, "");
var parts = cleaned.split(/[\/\\]/).filter(Boolean);
return parts.length ? parts[parts.length - 1] : value;
}
function updateReportTitleFromPath() {
var inferred = (GIT_MODE && GIT_LABEL) ? GIT_LABEL : inferTitleFromPath(pathInput.value || "");
if (!reportTitleTouched) {
reportTitleInput.value = inferred;
}
var title = reportTitleInput.value || inferred;
if (liveReportTitle) liveReportTitle.textContent = title;
if (reportTitlePreview) reportTitlePreview.textContent = title;
document.title = "OxideSLOC | " + title;
var projectPath = (pathInput.value || "").trim();
if (navProjectPill && navProjectTitle) {
if (projectPath.length > 0) {
navProjectTitle.textContent = inferred;
navProjectPill.classList.add("visible");
} else {
navProjectTitle.textContent = "";
navProjectPill.classList.remove("visible");
}
}
}
function updateMixedPolicyUI() {
var key = mixedLinePolicy.value || "code_only";
var info = mixedPolicyInfo[key];
document.getElementById("mixed-policy-description").textContent = info.description;
document.getElementById("mixed-policy-example").textContent = info.example;
}
function updatePythonDocstringUI() {
var checked = !!pythonDocstrings.checked;
document.getElementById("python-docstring-example").textContent = checked
? 'def greet():\n """Greet the user.""" ← comment\n print("hi")'
: 'def greet():\n """Greet the user.""" ← not counted\n print("hi")';
document.getElementById("python-docstring-live-help").textContent = checked
? "Enabled: docstrings contribute to comment-style totals."
: "Disabled: docstrings are not counted as comment content.";
}
function renderPresetChips(targetId, chips) {
var target = document.getElementById(targetId);
if (!target) return;
target.innerHTML = (chips || []).map(function (chip) {
return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
}).join('');
}
function updatePresetDescriptions() {
var scanInfo = scanPresetInfo[scanPreset.value];
if (!scanInfo) return;
document.getElementById("scan-preset-description").textContent = scanInfo.description;
document.getElementById("scan-preset-example").textContent = scanInfo.example;
document.getElementById("scan-preset-note").textContent = scanInfo.note;
renderPresetChips("scan-preset-summary", scanInfo.chips);
}
function applyScanPreset() {
var info = scanPresetInfo[scanPreset.value];
if (!info || !info.apply) return;
mixedLinePolicy.value = info.apply.mixed;
pythonDocstrings.checked = !!info.apply.docstrings;
document.getElementById("generated_file_detection").value = info.apply.generated;
document.getElementById("minified_file_detection").value = info.apply.minified;
document.getElementById("vendor_directory_detection").value = info.apply.vendor;
document.getElementById("include_lockfiles").value = info.apply.lockfiles;
document.getElementById("binary_file_behavior").value = info.apply.binary;
updateMixedPolicyUI();
updatePythonDocstringUI();
}
function updateReview() {
var scanSummary = document.getElementById("review-scan-summary");
var countSummary = document.getElementById("review-count-summary");
var artifactSummary = document.getElementById("review-artifact-summary");
var outputSummary = document.getElementById("review-output-summary");
var previewSummary = document.getElementById("review-preview-summary");
var readinessSummary = document.getElementById("review-readiness-summary");
var includeText = document.getElementById("include_globs").value.trim();
var excludeText = document.getElementById("exclude_globs").value.trim();
var sidePathPreview = document.getElementById("side-path-preview");
var sideOutputPreview = document.getElementById("side-output-preview");
var sideTitlePreview = document.getElementById("side-title-preview");
if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "(no path)"; }
if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
if (sideTitlePreview) {
var rt = document.getElementById("report_title");
sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
}
scanSummary.innerHTML = ""
+ "<li>Path: " + escapeHtml(pathInput.value || "(no path set)") + "</li>"
+ "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
+ "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
countSummary.innerHTML = ""
+ "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
+ "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
+ "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
+ "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
+ "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
+ "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
+ "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
+ "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
artifactSummary.innerHTML = "<li>HTML, PDF, JSON, CSV, XLSX (always generated)</li>";
outputSummary.innerHTML = ""
+ "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
+ "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value) || "project") + "</li>";
if (previewSummary) {
if (GIT_MODE) {
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>';
} else {
var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
var statMap = {};
statButtons.forEach(function (button) {
var valueNode = button.querySelector('.scope-stat-value');
statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
});
previewSummary.innerHTML = ''
+ '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
+ '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
+ '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
+ '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
+ '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
+ '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
if (readinessSummary) {
readinessSummary.innerHTML = ''
+ '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
+ '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
+ '<li>Ready to run: ' + (pathInput.value ? 'yes' : 'no') + '</li>';
}
} // end else (non-GIT_MODE)
}
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function isPythonVisible() {
return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
}
function syncPythonVisibility() {
var html = previewPanel.textContent || "";
var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
pythonWraps.forEach(function (node) {
node.classList.toggle("hidden", !hasPython);
});
}
function attachPreviewInteractions() {
var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
var treeContainer = previewPanel.querySelector(".file-explorer-tree");
var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
var filterSelect = previewPanel.querySelector("#explorer-filter-select");
var searchInput = previewPanel.querySelector("#explorer-search");
var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
var activeFilter = "all";
var activeLanguage = "";
var searchTerm = "";
var currentSortKey = null;
var currentSortOrder = "asc";
var childRows = {};
rows.forEach(function (row) {
var parentId = row.getAttribute("data-parent-id") || "";
var rowId = row.getAttribute("data-row-id") || "";
if (!childRows[parentId]) childRows[parentId] = [];
childRows[parentId].push(rowId);
});
function rowById(id) {
return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
}
function hasCollapsedAncestor(row) {
var parentId = row.getAttribute("data-parent-id");
while (parentId) {
var parent = rowById(parentId);
if (!parent) break;
if (parent.getAttribute("data-expanded") === "false") return true;
parentId = parent.getAttribute("data-parent-id");
}
return false;
}
function updateToggleGlyph(row) {
var toggle = row.querySelector(".tree-toggle");
if (!toggle) return;
toggle.textContent = row.getAttribute("data-expanded") === "false" ? "▸" : "▾";
}
function rowSortValue(row, key) {
return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
}
function updateSortButtons() {
sortButtons.forEach(function (button) {
var isActive = button.getAttribute("data-sort-key") === currentSortKey;
var indicator = button.querySelector(".tree-sort-indicator");
button.classList.toggle("active", isActive);
button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
if (indicator) {
indicator.textContent = !isActive ? "↕" : (currentSortOrder === "asc" ? "↑" : "↓");
}
});
}
function sortSiblingRows() {
if (!treeContainer) {
updateSortButtons();
return;
}
var rowMap = {};
var childrenMap = {};
rows.forEach(function (row) {
var rowId = row.getAttribute("data-row-id");
var parentId = row.getAttribute("data-parent-id") || "";
rowMap[rowId] = row;
if (!childrenMap[parentId]) childrenMap[parentId] = [];
childrenMap[parentId].push(rowId);
});
Object.keys(childrenMap).forEach(function (parentId) {
if (!parentId) return;
childrenMap[parentId].sort(function (a, b) {
var rowA = rowMap[a];
var rowB = rowMap[b];
if (!currentSortKey) {
return Number(a) - Number(b);
}
var valueA = rowSortValue(rowA, currentSortKey);
var valueB = rowSortValue(rowB, currentSortKey);
if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
var fallbackA = rowSortValue(rowA, "name");
var fallbackB = rowSortValue(rowB, "name");
if (fallbackA < fallbackB) return -1;
if (fallbackA > fallbackB) return 1;
return Number(a) - Number(b);
});
});
var orderedIds = [];
function pushChildren(parentId) {
(childrenMap[parentId] || []).forEach(function (childId) {
orderedIds.push(childId);
pushChildren(childId);
});
}
(childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
orderedIds.push(topId);
pushChildren(topId);
});
orderedIds.forEach(function (id) {
if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
});
updateSortButtons();
}
function updateLanguageButtons() {
languageButtons.forEach(function (button) {
var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
var isActive = languageValue === activeLanguage;
button.classList.toggle("active", isActive);
});
}
function rowSelfMatches(row) {
var kind = row.getAttribute("data-kind");
var status = row.getAttribute("data-status");
var language = (row.getAttribute("data-language") || "").toLowerCase();
var name = row.getAttribute("data-name-lower") || "";
var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
var passesLanguage = !activeLanguage || language === activeLanguage;
return passesFilter && passesSearch && passesLanguage;
}
function hasMatchingDescendant(rowId) {
return (childRows[rowId] || []).some(function (childId) {
var childRow = rowById(childId);
return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
});
}
function rowMatches(row) {
if (rowSelfMatches(row)) return true;
return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
}
function resetViewState() {
activeFilter = "all";
activeLanguage = "";
searchTerm = "";
currentSortKey = null;
currentSortOrder = "asc";
dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
if (searchInput) searchInput.value = "";
if (filterSelect) filterSelect.value = "all";
updateLanguageButtons();
}
function applyVisibility() {
rows.forEach(function (row) {
var visible = rowMatches(row) && !hasCollapsedAncestor(row);
row.classList.toggle("hidden-by-filter", !visible);
row.style.display = visible ? "grid" : "none";
});
buttons.forEach(function (button) {
button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
});
if (filterSelect) filterSelect.value = activeFilter;
}
var submoduleChips = Array.prototype.slice.call(previewPanel.querySelectorAll('.submodule-preview-chip[data-sub-stats]'));
var baseRepoBtn = previewPanel.querySelector('.submodule-base-repo-btn');
var originalStats = {};
buttons.forEach(function (btn) {
var f = btn.getAttribute('data-filter');
var v = btn.querySelector('.scope-stat-value');
if (f && v) originalStats[f] = v.textContent;
});
function applySubmoduleStats(statsJson) {
try {
var s = JSON.parse(statsJson);
buttons.forEach(function (btn) {
var f = btn.getAttribute('data-filter');
var v = btn.querySelector('.scope-stat-value');
if (!v) return;
if (f === 'dir') v.textContent = s.dirs;
else if (f === 'file') v.textContent = s.files;
else if (f === 'supported') v.textContent = s.supported;
else if (f === 'skipped') v.textContent = s.skipped;
else if (f === 'unsupported') v.textContent = s.unsupported;
});
} catch (e) {}
}
function restoreBaseRepoStats() {
buttons.forEach(function (btn) {
var f = btn.getAttribute('data-filter');
var v = btn.querySelector('.scope-stat-value');
if (v && originalStats[f]) v.textContent = originalStats[f];
});
submoduleChips.forEach(function (c) { c.classList.remove('active'); });
if (baseRepoBtn) baseRepoBtn.style.display = 'none';
}
submoduleChips.forEach(function (chip) {
chip.addEventListener('click', function () {
var statsJson = chip.getAttribute('data-sub-stats');
if (!statsJson) return;
submoduleChips.forEach(function (c) { c.classList.remove('active'); });
chip.classList.add('active');
applySubmoduleStats(statsJson);
if (baseRepoBtn) baseRepoBtn.style.display = '';
});
});
if (baseRepoBtn) {
baseRepoBtn.addEventListener('click', function () {
restoreBaseRepoStats();
resetViewState();
sortSiblingRows();
applyVisibility();
});
}
buttons.forEach(function (button) {
button.addEventListener("click", function () {
var filterValue = button.getAttribute("data-filter") || "all";
if (filterValue === "reset-view") {
restoreBaseRepoStats();
resetViewState();
sortSiblingRows();
applyVisibility();
return;
}
activeFilter = filterValue;
applyVisibility();
});
});
rows.forEach(function (row) {
updateToggleGlyph(row);
var toggle = row.querySelector(".tree-toggle");
if (toggle) {
toggle.addEventListener("click", function () {
var expanded = row.getAttribute("data-expanded") !== "false";
row.setAttribute("data-expanded", expanded ? "false" : "true");
updateToggleGlyph(row);
applyVisibility();
});
}
});
actionButtons.forEach(function (button) {
button.addEventListener("click", function () {
var action = button.getAttribute("data-explorer-action");
if (action === "expand-all") {
dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
} else if (action === "collapse-all") {
dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
} else if (action === "clear-filters") {
resetViewState();
}
sortSiblingRows();
applyVisibility();
});
});
if (filterSelect) {
filterSelect.addEventListener("change", function () {
activeFilter = filterSelect.value || "all";
applyVisibility();
});
}
languageButtons.forEach(function (button) {
button.addEventListener("click", function () {
activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
updateLanguageButtons();
applyVisibility();
});
});
sortButtons.forEach(function (button) {
button.addEventListener("click", function () {
var sortKey = button.getAttribute("data-sort-key");
if (currentSortKey === sortKey) {
currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
} else {
currentSortKey = sortKey;
currentSortOrder = "asc";
}
sortSiblingRows();
applyVisibility();
});
});
if (searchInput) {
searchInput.addEventListener("input", function () {
searchTerm = searchInput.value.trim().toLowerCase();
applyVisibility();
});
}
updateLanguageButtons();
sortSiblingRows();
applyVisibility();
}
function loadPreview() {
if (!previewPanel || !pathInput) return;
if (GIT_MODE) {
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>';
return;
}
var path = pathInput.value.trim();
var zeroWarn = document.getElementById('zero-files-warning');
if (!path) {
previewPanel.innerHTML = '<div class="preview-hint">Enter a project path above to preview the files that will be in scope.</div>';
if (zeroWarn) zeroWarn.style.display = 'none';
return;
}
var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
if (window._previewInterval) { clearInterval(window._previewInterval); window._previewInterval = null; }
if (window._previewElapsedTimer) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; }
var myGen = ++_previewGen;
var _prevMsgs = [
'Scanning directory structure…',
'Detecting file types…',
'Applying include / exclude filters…',
'Estimating file counts…',
'Building scope preview…',
'Almost there…'
];
var _prevMsgIdx = 0;
var _prevStart = Date.now();
previewPanel.innerHTML =
'<div class="preview-loading">' +
'<div class="preview-spinner"></div>' +
'<div class="preview-loading-text">' +
'<div class="preview-loading-msg" id="plm">' + _prevMsgs[0] + '</div>' +
'<div class="preview-loading-elapsed" id="ple">0s elapsed</div>' +
'</div></div>';
var _sizeTextEl = document.getElementById('project-size-text');
if (_sizeTextEl) _sizeTextEl.textContent = 'Project size: Detecting…';
window._previewInterval = setInterval(function() {
if (myGen !== _previewGen) { clearInterval(window._previewInterval); window._previewInterval = null; return; }
_prevMsgIdx = (_prevMsgIdx + 1) % _prevMsgs.length;
var ml = document.getElementById('plm');
if (ml) ml.textContent = _prevMsgs[_prevMsgIdx];
}, 1500);
window._previewElapsedTimer = setInterval(function() {
if (myGen !== _previewGen) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; return; }
var el = document.getElementById('ple');
if (el) el.textContent = Math.round((Date.now() - _prevStart) / 1000) + 's elapsed';
}, 1000);
var previewUrl = "/preview?path=" + encodeURIComponent(path)
+ "&include_globs=" + encodeURIComponent(includeValue)
+ "&exclude_globs=" + encodeURIComponent(excludeValue);
fetch(previewUrl)
.then(function (response) { return response.text(); })
.then(function (html) {
if (myGen !== _previewGen) return;
clearInterval(window._previewInterval); window._previewInterval = null;
clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
previewPanel.innerHTML = html;
attachPreviewInteractions();
syncPythonVisibility();
updateReview();
setTimeout(collapseLanguagePills, 50);
var explorerWrap = previewPanel.querySelector('.explorer-wrap');
var projectSize = explorerWrap ? explorerWrap.getAttribute('data-project-size') : null;
var sizeText = document.getElementById('project-size-text');
var sizeBtn = document.getElementById('project-size-btn');
// In server mode with upload sizes available, keep the compressed/original pair.
if (SERVER_MODE && window._lastUploadSizes) {
var us = window._lastUploadSizes;
if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(us.original_bytes) +
' \xb7 Compressed: ' + fmtBytes(us.compressed_bytes);
if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(us.original_bytes) +
' — Compressed archive size: ' + fmtBytes(us.compressed_bytes);
} else if (sizeText && projectSize) {
sizeText.textContent = 'Project size: ' + projectSize;
if (sizeBtn) sizeBtn.title = 'Total disk size of the selected project directory: ' + projectSize;
} else if (sizeText) {
sizeText.textContent = 'Project size: —';
}
if (zeroWarn) {
var supportedBtn = previewPanel.querySelector('.scope-stat-button.supported .scope-stat-value');
var filesBtn = previewPanel.querySelector('.scope-stat-button[data-filter="file"] .scope-stat-value');
var supportedCount = supportedBtn ? parseInt(supportedBtn.textContent, 10) : -1;
var fileCount = filesBtn ? parseInt(filesBtn.textContent, 10) : -1;
if (supportedCount === 0 && fileCount > 0) {
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).';
zeroWarn.style.display = '';
} else {
zeroWarn.style.display = 'none';
}
}
})
.catch(function (err) {
if (myGen !== _previewGen) return;
clearInterval(window._previewInterval); window._previewInterval = null;
clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
});
}
function pickDirectory(targetInput, kind) {
if (!targetInput) {
showBannerToast("Directory picker: input element not found.", true);
return;
}
if (SERVER_MODE) {
if (kind === 'output') {
showBannerToast(
'Server mode: type the output path directly into the field — the path must exist on the server, not your local machine.',
false,
{ top: true, icon: '📁' }
);
return;
}
var inputEl = kind === 'coverage'
? document.getElementById('cov-upload-input')
: document.getElementById('dir-upload-input');
if (!inputEl) return;
inputEl.onchange = function () {
var files = inputEl.files;
if (!files || files.length === 0) return;
var browseBtn = targetInput === pathInput ? browsePath : browseOutputDir;
if (browseBtn) browseBtn.disabled = true;
function fileToBase64(file) {
return new Promise(function (resolve, reject) {
var reader = new FileReader();
reader.onload = function () {
var b64 = reader.result.split(',')[1];
resolve(b64);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
if (kind === 'coverage') {
var f = files[0];
if (previewPanel && targetInput === pathInput)
previewPanel.innerHTML = '<div class="preview-error">Uploading coverage file…</div>';
fileToBase64(f).then(function (b64) {
return fetch('/api/upload-file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: f.name, content: b64 })
}).then(function (r) { return r.json(); });
})
.then(function (d) {
if (d && d.tmp_path) {
if (coverageInput) coverageInput.value = d.tmp_path;
setCovStatus('idle');
} else if (d && d.error) { showBannerToast(d.error, true); }
})
.catch(function (e) { showBannerToast('Upload failed: ' + String(e), true); })
.finally(function () { if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; });
} else {
// ── Filter to source-code files only ─────────────────────────
// Binary, generated, and dependency files (node_modules, .git,
// build artifacts) are skipped so they are never uploaded.
var CODE_EXTS = new Set([
'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
'asm','s','S','objc','lisp','el','rkt','ml','mli','ocaml','v','sv','vhd','vhdl',
'tf','hcl','proto','thrift','avsc','graphql','gql'
]);
var codeFiles = [];
for (var i = 0; i < files.length; i++) {
var f = files[i];
var name = f.name;
if (name === 'Makefile' || name === 'Dockerfile' || name === 'Gemfile' ||
name === 'Rakefile' || name === 'Procfile' || name === 'Justfile') {
codeFiles.push(f); continue;
}
var dot = name.lastIndexOf('.');
if (dot >= 0 && CODE_EXTS.has(name.slice(dot + 1).toLowerCase())) codeFiles.push(f);
}
// Collect specific .git metadata files for server-side git detection.
// These have no source extension so they are excluded by the loop above,
// but the server needs them to read branch/commit/author without running git.
var gitMetaFiles = [];
for (var i = 0; i < files.length; i++) {
var f = files[i];
var rp = (f.webkitRelativePath || '').replace(/\\/g, '/');
var gitIdx = rp.indexOf('/.git/');
if (gitIdx < 0) continue;
var gitRel = rp.slice(gitIdx + 1);
if (gitRel === '.git/HEAD' || gitRel === '.git/packed-refs' ||
gitRel === '.git/logs/HEAD' ||
gitRel.startsWith('.git/refs/heads/') ||
gitRel.startsWith('.git/refs/tags/')) {
gitMetaFiles.push(f);
}
}
var uploadFiles = codeFiles.concat(gitMetaFiles);
var total = files.length;
var kept = codeFiles.length;
if (kept === 0) {
if (previewPanel && targetInput === pathInput)
previewPanel.innerHTML = '<div class="preview-error">No supported source files found in the selected folder (' + total.toLocaleString() + ' files scanned).</div>';
if (browseBtn) browseBtn.disabled = false;
inputEl.value = '';
return;
}
// ── Helper: apply upload result to UI ────────────────────────
// sizes = {compressed_bytes, original_bytes} from the server response (server mode only).
function applyUploadResult(tmpPath, sizes) {
targetInput.value = tmpPath;
scrollInputToEnd(targetInput);
if (sizes && SERVER_MODE) {
window._lastUploadSizes = sizes;
// Immediately show both sizes before preview loads.
var sizeText = document.getElementById('project-size-text');
var sizeBtn = document.getElementById('project-size-btn');
if (sizeText) {
sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
' · Compressed: ' + fmtBytes(sizes.compressed_bytes);
}
if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
' — Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
}
if (targetInput === pathInput) {
updateReportTitleFromPath();
autoSetOutputDir(tmpPath);
fetchProjectHistory(tmpPath);
loadPreview();
suggestCoverageFile(tmpPath);
}
updateReview();
if (browseBtn) browseBtn.disabled = false;
inputEl.value = '';
}
// ── Path A: tar.gz via native CompressionStream (Chrome 80+, FF 113+, Safari 16.4+)
if (typeof CompressionStream !== 'undefined') {
if (previewPanel && targetInput === pathInput)
previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files…</div>';
// Build a minimal POSIX ustar tar header for a single file entry.
function buildUstarHeader(filePath, fileSize) {
var BLOCK = 512;
var hdr = new Uint8Array(BLOCK);
var enc = new TextEncoder();
function wStr(off, len, s) {
var b = enc.encode(s);
for (var i = 0; i < Math.min(b.length, len); i++) hdr[off + i] = b[i];
}
function wOct(off, len, val) {
var s = val.toString(8);
while (s.length < len - 1) s = '0' + s;
wStr(off, len, s + '\0');
}
// Long-path split: ustar name ≤99 chars, prefix ≤154 chars.
var name = filePath, prefix = '';
if (filePath.length > 99) {
var split = filePath.lastIndexOf('/', 154);
if (split > 0 && filePath.length - split - 1 <= 99) {
prefix = filePath.substring(0, split);
name = filePath.substring(split + 1);
} else { name = filePath.substring(0, 99); }
}
wStr(0, 100, name); // name
wOct(100, 8, 0o000644); // mode
wOct(108, 8, 0); // uid
wOct(116, 8, 0); // gid
wOct(124, 12, fileSize); // size
wOct(136, 12, 0); // mtime (epoch)
for (var i = 148; i < 156; i++) hdr[i] = 32; // checksum placeholder = spaces
hdr[156] = 48; // type flag '0' = regular file
wStr(157, 100, ''); // linkname
wStr(257, 6, 'ustar'); // magic
wStr(263, 2, '00'); // version
wStr(265, 32, ''); // uname
wStr(297, 32, ''); // gname
wOct(329, 8, 0); // devmajor
wOct(337, 8, 0); // devminor
wStr(345, 155, prefix); // prefix
// Compute checksum (sum of all bytes, placeholder = 32).
var chk = 0;
for (var i = 0; i < BLOCK; i++) chk += hdr[i];
var cs = chk.toString(8);
while (cs.length < 6) cs = '0' + cs;
wStr(148, 8, cs + '\0 ');
return hdr;
}
// Build tar.gz one file at a time, piping through CompressionStream.
// RAM usage = compressed output buffer + one file at a time.
(async function () {
try {
var BLOCK = 512;
var cs = new CompressionStream('gzip');
var writer = cs.writable.getWriter();
var chunks = [];
var reader = cs.readable.getReader();
var collecting = (async function () {
while (true) { var r = await reader.read(); if (r.done) break; chunks.push(r.value); }
})();
for (var i = 0; i < uploadFiles.length; i++) {
var file = uploadFiles[i];
var path = file.webkitRelativePath || file.name;
var buf = await file.arrayBuffer();
var data = new Uint8Array(buf);
// Header block
await writer.write(buildUstarHeader(path, data.length));
// Data padded to 512-byte boundary
if (data.length > 0) {
var padded = Math.ceil(data.length / BLOCK) * BLOCK;
var block = new Uint8Array(padded);
block.set(data);
await writer.write(block);
}
if ((i + 1) % 50 === 0 || i === uploadFiles.length - 1) {
if (previewPanel && targetInput === pathInput)
previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i + 1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files…</div>';
}
}
// End-of-archive: two 512-byte zero blocks
await writer.write(new Uint8Array(BLOCK * 2));
await writer.close();
await collecting;
var blob = new Blob(chunks, { type: 'application/gzip' });
var sizeMB = (blob.size / 1048576).toFixed(1);
if (previewPanel && targetInput === pathInput)
previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + (total !== kept ? kept.toLocaleString() + ' of ' + total.toLocaleString() + ' files' : kept.toLocaleString() + ' files') + ')…</div>';
var resp = await fetch('/api/upload-tarball', {
method: 'POST',
headers: { 'Content-Type': 'application/gzip' },
body: blob
});
var d = await resp.json();
if (d && d.tmp_path) {
applyUploadResult(d.tmp_path, {
compressed_bytes: d.compressed_bytes || 0,
original_bytes: d.original_bytes || 0
});
} else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
} catch (e) {
showBannerToast('Upload failed: ' + String(e), true);
if (browseBtn) browseBtn.disabled = false;
inputEl.value = '';
}
})();
} else {
// ── Path B: Legacy fallback — sequential JSON+base64 batches ─
// Used only on browsers that lack CompressionStream (pre-2023).
var BATCH = 200;
var batches = [];
for (var b = 0; b < uploadFiles.length; b += BATCH) batches.push(uploadFiles.slice(b, b + BATCH));
var totalBatches = batches.length;
if (previewPanel && targetInput === pathInput)
previewPanel.innerHTML = '<div class="preview-error">Uploading ' + kept.toLocaleString() + ' code file' + (kept === 1 ? '' : 's') + (total !== kept ? ' of ' + total.toLocaleString() + ' total' : '') + '…</div>';
function sendBatch(idx, currentUploadId, lastTmpPath) {
if (idx >= totalBatches) { applyUploadResult(lastTmpPath); return; }
if (previewPanel && targetInput === pathInput && totalBatches > 1)
previewPanel.innerHTML = '<div class="preview-error">Uploading batch ' + (idx + 1) + ' of ' + totalBatches + '…</div>';
Promise.all(batches[idx].map(function (file) {
return fileToBase64(file).then(function (b64) {
return { path: file.webkitRelativePath || file.name, content: b64 };
});
})).then(function (fileList) {
var body = { files: fileList };
if (currentUploadId) body.upload_id = currentUploadId;
return fetch('/api/upload-directory', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}).then(function (r) { return r.json(); });
}).then(function (d) {
if (d && d.tmp_path) sendBatch(idx + 1, d.upload_id || currentUploadId, d.tmp_path);
else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
}).catch(function (e) {
showBannerToast('Upload failed: ' + String(e), true);
if (browseBtn) browseBtn.disabled = false; inputEl.value = '';
});
}
sendBatch(0, null, '');
}
}
};
inputEl.click();
return;
}
var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
if (browseButton) browseButton.disabled = true;
if (previewPanel && targetInput === pathInput) {
previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
}
fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "¤t=" + encodeURIComponent(targetInput.value || ""))
.then(function (response) { return response.ok ? response.json() : { cancelled: true }; })
.then(function (data) {
if (data && data.selected_path) {
targetInput.value = data.selected_path;
scrollInputToEnd(targetInput);
if (targetInput === pathInput) {
updateReportTitleFromPath();
autoSetOutputDir(data.selected_path);
fetchProjectHistory(data.selected_path);
loadPreview();
suggestCoverageFile(data.selected_path);
}
updateReview();
} else if (targetInput === pathInput) {
loadPreview();
}
})
.catch(function () {
window.alert("Directory picker request failed.");
if (previewPanel && targetInput === pathInput) {
previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
}
})
.finally(function () {
if (browseButton) browseButton.disabled = false;
});
}
if (themeToggle) {
themeToggle.addEventListener("click", function () {
var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
applyTheme(nextTheme);
try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
});
}
stepButtons.forEach(function (button) {
button.addEventListener("click", function () {
setStep(Number(button.getAttribute("data-step-target")));
});
});
Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
button.addEventListener("click", function () {
setStep(Number(button.getAttribute("data-step-target")) || 1);
});
});
Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
button.addEventListener("click", function () {
updateReview();
setStep(Number(button.getAttribute("data-next")));
});
});
Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
button.addEventListener("click", function () {
setStep(Number(button.getAttribute("data-prev")));
});
});
document.addEventListener("keydown", function (e) {
var tag = (document.activeElement || {}).tagName || "";
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
if (e.altKey || e.ctrlKey || e.metaKey) return;
if (e.key === "ArrowRight" && currentStep < 4) { updateReview(); setStep(currentStep + 1); }
else if (e.key === "ArrowLeft" && currentStep > 1) { setStep(currentStep - 1); }
});
if (useSamplePath) {
useSamplePath.addEventListener("click", function () {
pathInput.value = "tests/fixtures/basic";
updateReportTitleFromPath();
autoSetOutputDir("tests/fixtures/basic");
loadPreview();
suggestCoverageFile("tests/fixtures/basic");
});
}
if (useDefaultOutput) {
useDefaultOutput.addEventListener("click", function () {
delete outputDirInput.dataset.userEdited;
autoSetOutputDir(pathInput ? pathInput.value : "");
updateReview();
});
}
if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
// ── Drag-and-drop directory upload (server mode only) ─────────────────
// Dropping a folder onto the path field bypasses Chrome's
// "Upload X files to this site?" confirmation dialog.
async function readDirRecursively(dirEntry, basePath) {
var reader = dirEntry.createReader();
var all = [];
for (;;) {
var batch = await new Promise(function(res) { reader.readEntries(res, function() { res([]); }); });
if (!batch.length) break;
for (var i = 0; i < batch.length; i++) all.push(batch[i]);
}
var SKIP = new Set(['node_modules','.git','.hg','vendor','dist','build','target','__pycache__','.svn','.idea','.vscode']);
var out = [];
for (var i = 0; i < all.length; i++) {
var sub = all[i];
if (sub.isFile) {
var f = await new Promise(function(res) { sub.file(res); });
out.push({ file: f, path: basePath + '/' + sub.name });
} else if (sub.isDirectory && !SKIP.has(sub.name)) {
var nested = await readDirRecursively(sub, basePath + '/' + sub.name);
for (var j = 0; j < nested.length; j++) out.push(nested[j]);
}
}
return out;
}
function setupPathDropZone() {
if (!SERVER_MODE || !pathInput) return;
var CODE_EXTS = new Set([
'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
'asm','s','S','lisp','el','rkt','ml','mli','tf','hcl','proto','thrift','graphql','gql'
]);
pathInput.addEventListener('dragover', function(e) {
e.preventDefault();
pathInput.classList.add('drag-over');
});
pathInput.addEventListener('dragleave', function() { pathInput.classList.remove('drag-over'); });
pathInput.addEventListener('drop', function(e) {
e.preventDefault();
pathInput.classList.remove('drag-over');
var items = e.dataTransfer.items;
if (!items || !items.length) return;
var dirEntry = null;
for (var i = 0; i < items.length; i++) {
var entry = items[i].webkitGetAsEntry && items[i].webkitGetAsEntry();
if (entry && entry.isDirectory) { dirEntry = entry; break; }
}
if (!dirEntry) { showBannerToast('Drop a project folder (not individual files).', true); return; }
var btn = browsePath;
if (btn) btn.disabled = true;
if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Reading folder contents…</div>';
readDirRecursively(dirEntry, dirEntry.name).then(async function(allEntries) {
var total = allEntries.length;
var codeEntries = allEntries.filter(function(e) {
var n = e.file.name;
if (n === 'Makefile' || n === 'Dockerfile' || n === 'Gemfile' || n === 'Rakefile' || n === 'Procfile' || n === 'Justfile') return true;
var dot = n.lastIndexOf('.');
return dot >= 0 && CODE_EXTS.has(n.slice(dot + 1).toLowerCase());
});
var kept = codeEntries.length;
if (kept === 0) {
if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">No supported source files found (' + total.toLocaleString() + ' files scanned).</div>';
if (btn) btn.disabled = false; return;
}
function finish(tmpPath, sizes) {
pathInput.value = tmpPath;
scrollInputToEnd(pathInput);
if (sizes) {
window._lastUploadSizes = sizes;
var sizeText = document.getElementById('project-size-text');
var sizeBtn = document.getElementById('project-size-btn');
if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
' · Compressed: ' + fmtBytes(sizes.compressed_bytes);
if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
' — Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
}
updateReportTitleFromPath();
autoSetOutputDir(tmpPath);
fetchProjectHistory(tmpPath);
loadPreview();
suggestCoverageFile(tmpPath);
updateReview();
if (btn) btn.disabled = false;
}
if (typeof CompressionStream === 'undefined') {
showBannerToast('Your browser lacks CompressionStream. Use the “Upload” button instead.', true);
if (btn) btn.disabled = false; return;
}
try {
if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files…</div>';
var BLOCK = 512;
var cs = new CompressionStream('gzip');
var wtr = cs.writable.getWriter();
var chunks = [];
var rdr = cs.readable.getReader();
var collecting = (async function() { while (true) { var r = await rdr.read(); if (r.done) break; chunks.push(r.value); } })();
function buildHdr(fp, sz) {
var hdr = new Uint8Array(BLOCK);
var enc = new TextEncoder();
function wS(o, l, s) { var b = enc.encode(s); for (var i = 0; i < Math.min(b.length, l); i++) hdr[o + i] = b[i]; }
function wO(o, l, v) { var s = v.toString(8); while (s.length < l - 1) s = '0' + s; wS(o, l, s + '\0'); }
var nm = fp, pfx = '';
if (fp.length > 99) { var sp = fp.lastIndexOf('/', 154); if (sp > 0 && fp.length - sp - 1 <= 99) { pfx = fp.substring(0, sp); nm = fp.substring(sp + 1); } else { nm = fp.substring(0, 99); } }
wS(0,100,nm); wO(100,8,0o000644); wO(108,8,0); wO(116,8,0); wO(124,12,sz); wO(136,12,0);
for (var i = 148; i < 156; i++) hdr[i] = 32;
hdr[156] = 48; wS(157,100,''); wS(257,6,'ustar'); wS(263,2,'00'); wS(265,32,''); wS(297,32,''); wO(329,8,0); wO(337,8,0); wS(345,155,pfx);
var chk = 0; for (var i = 0; i < BLOCK; i++) chk += hdr[i];
var cv = chk.toString(8); while (cv.length < 6) cv = '0' + cv; wS(148,8,cv+'\0 ');
return hdr;
}
for (var i = 0; i < codeEntries.length; i++) {
var ce = codeEntries[i];
var buf = await ce.file.arrayBuffer();
var data = new Uint8Array(buf);
await wtr.write(buildHdr(ce.path, data.length));
if (data.length > 0) { var padded = Math.ceil(data.length / BLOCK) * BLOCK; var blk = new Uint8Array(padded); blk.set(data); await wtr.write(blk); }
if ((i + 1) % 50 === 0 || i === codeEntries.length - 1)
if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i+1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files…</div>';
}
await wtr.write(new Uint8Array(BLOCK * 2));
await wtr.close();
await collecting;
var blob = new Blob(chunks, { type: 'application/gzip' });
var sizeMB = (blob.size / 1048576).toFixed(1);
if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + kept.toLocaleString() + ' files)…</div>';
var resp = await fetch('/api/upload-tarball', { method: 'POST', headers: { 'Content-Type': 'application/gzip' }, body: blob });
var d = await resp.json();
if (d && d.tmp_path) {
finish(d.tmp_path, { compressed_bytes: d.compressed_bytes || 0, original_bytes: d.original_bytes || 0 });
} else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (btn) btn.disabled = false; }
} catch (err) {
showBannerToast('Upload failed: ' + String(err), true);
if (btn) btn.disabled = false;
}
}).catch(function(err) {
showBannerToast('Could not read folder: ' + String(err), true);
if (btn) btn.disabled = false;
});
});
}
setupPathDropZone();
if (browseCoverage) {
browseCoverage.addEventListener("click", function () {
pickDirectory(coverageInput || pathInput, "coverage");
});
}
function setCovStatus(state, opts) {
if (!covScanStatus) return;
opts = opts || {};
covScanStatus.className = "cov-scan-status cov-scan-" + state;
if (state === "idle") { covScanStatus.innerHTML = ""; return; }
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>';
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>';
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>';
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>';
var icons = { scanning: ICON_SCAN, found: ICON_OK, hint: ICON_WARN, none: ICON_NONE };
var html = '<div class="cov-scan-inner"><div class="cov-scan-icon">' + (icons[state] || "") + '</div><div class="cov-scan-body">';
if (state === "scanning") {
html += '<div class="cov-scan-title">Scanning project for coverage files…</div>';
} else if (state === "found") {
var tb = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
html += '<div class="cov-scan-title">Coverage file auto-detected! ' + tb + '</div>';
html += '<div class="cov-scan-sub">' + escapeHtml(opts.found) + '</div>';
html += '<div class="cov-scan-actions"><button type="button" class="cov-scan-use cov-scan-remove">Remove</button></div>';
} else if (state === "hint") {
var tb2 = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
html += '<div class="cov-scan-title">' + tb2 + ' project — no coverage report found yet</div>';
html += '<div class="cov-scan-sub">Generate a report with your test framework\'s coverage tool, then browse to the output file. Supported: LCOV .info · Cobertura XML · JaCoCo XML</div>';
} else if (state === "none") {
html += '<div class="cov-scan-title">No coverage files detected in this project</div>';
html += '<div class="cov-scan-sub">Supported: LCOV .info · Cobertura XML · JaCoCo XML</div>';
}
html += '</div></div>';
covScanStatus.innerHTML = html;
if (state === "found") {
var useBtn = covScanStatus.querySelector(".cov-scan-use");
if (useBtn) useBtn.addEventListener("click", function () {
if (coverageInput) coverageInput.value = "";
covAutoFilled = false;
setCovStatus("idle");
});
}
}
function suggestCoverageFile(projectPath) {
if (!coverageInput || !covScanStatus) return;
if (coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
if (covAutoFilled) { coverageInput.value = ""; covAutoFilled = false; }
clearTimeout(coverageSuggestTimer);
if (!projectPath || !projectPath.trim()) { setCovStatus("idle"); return; }
setCovStatus("scanning");
coverageSuggestTimer = setTimeout(function () {
fetch("/api/suggest-coverage?path=" + encodeURIComponent(projectPath))
.then(function (r) { return r.json(); })
.then(function (d) {
if (coverageInput && coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
if (!d) { setCovStatus("none"); return; }
if (d.found) {
if (coverageInput) { coverageInput.value = d.found; covAutoFilled = true; }
setCovStatus("found", { found: d.found, tool: d.tool });
} else if (d.tool && d.hint) {
setCovStatus("hint", { tool: d.tool, hint: d.hint });
} else {
setCovStatus("none");
}
})
.catch(function () { setCovStatus("idle"); });
}, 600);
}
if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
if (coverageInput) coverageInput.addEventListener("input", function () {
covAutoFilled = false;
if (!this.value.trim()) setCovStatus("idle");
});
// ── Language pill overflow: collapse to "+N more" chip ─────────────
function collapseLanguagePills() {
var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
rows.forEach(function(row) {
// Remove any previous overflow chip
var prev = row.querySelector('.lang-overflow-chip');
if (prev) prev.remove();
var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
pills.forEach(function(p) { p.style.display = ''; });
if (!pills.length) return;
// Measure after restoring all pills
var containerRight = row.getBoundingClientRect().right;
var hidden = [];
for (var i = pills.length - 1; i >= 1; i--) {
var rect = pills[i].getBoundingClientRect();
if (rect.right > containerRight + 2) {
hidden.unshift(pills[i]);
pills[i].style.display = 'none';
} else {
break;
}
}
if (hidden.length) {
var chip = document.createElement('button');
chip.type = 'button';
chip.className = 'language-pill lang-overflow-chip';
var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
row.appendChild(chip);
}
});
}
// Run after preview loads (preview panel populates language pills)
var _origLoadPreviewCb = window.__previewLoaded;
document.addEventListener('previewLoaded', collapseLanguagePills);
window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
setTimeout(collapseLanguagePills, 400);
// ── Project history & output dir auto-set ──────────────────────────
var wsOutputRoot = document.getElementById("ws-output-root");
var wsScanCount = document.getElementById("ws-scan-count");
var wsLastScan = document.getElementById("ws-last-scan");
var historyBadge = document.getElementById("path-history-badge");
var historyTimer = null;
var wsOutputLink = document.getElementById("ws-output-link");
function syncStripOutputRoot() {
var val = outputDirInput ? outputDirInput.value : "";
var display = val || "project/sloc";
if (wsOutputRoot) wsOutputRoot.textContent = display;
if (wsOutputLink) wsOutputLink.dataset.folder = val;
}
function scrollInputToEnd(input) {
if (!input) return;
// Defer so the DOM has the new value before we measure scroll width.
requestAnimationFrame(function () {
input.scrollLeft = input.scrollWidth;
input.selectionStart = input.selectionEnd = input.value.length;
});
}
function autoSetOutputDir(projectPath) {
if (!outputDirInput || outputDirInput.dataset.userEdited) return;
if (GIT_MODE && GIT_OUTPUT_DIR) {
outputDirInput.value = GIT_OUTPUT_DIR;
scrollInputToEnd(outputDirInput);
syncStripOutputRoot();
updateReview();
return;
}
if (!projectPath || !projectPath.trim()) return;
var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
outputDirInput.value = cleaned + "/sloc";
scrollInputToEnd(outputDirInput);
syncStripOutputRoot();
updateReview();
}
var wsBranch = document.getElementById("ws-branch");
function fetchProjectHistory(projectPath) {
if (!projectPath || !projectPath.trim()) {
if (wsScanCount) wsScanCount.textContent = "—";
if (wsLastScan) wsLastScan.textContent = "—";
if (wsBranch) wsBranch.textContent = "—";
if (historyBadge) historyBadge.style.display = "none";
return;
}
fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) {
if (!data) return;
var countStr = data.scan_count > 0
? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
: "never";
var tsStr = data.last_scan_timestamp
? data.last_scan_timestamp.replace(" UTC","")
: "—";
if (wsScanCount) wsScanCount.textContent = countStr;
if (wsLastScan) wsLastScan.textContent = tsStr;
if (wsBranch) wsBranch.textContent = data.last_git_branch || "—";
if (data.scan_count > 0) {
if (historyBadge) {
var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
historyBadge.textContent = data.scan_count + " previous scan" +
(data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
"Last: " + (data.last_scan_timestamp || "—") +
" — " + (data.last_scan_code_lines ? (function(v){return v>=1e6?(v/1e6).toFixed(1).replace(/\.0$/,'')+'M':v>=1e4?(v/1e3).toFixed(1).replace(/\.0$/,'')+'K':Number(v).toLocaleString();})(data.last_scan_code_lines) : "?") + " code lines.";
historyBadge.className = "path-history-badge found";
historyBadge.style.display = "";
}
} else {
if (historyBadge) historyBadge.style.display = "none";
}
})
.catch(function () {});
}
function onPathChange() {
var val = pathInput ? pathInput.value : "";
// Discard stale upload sizes when the user edits the path manually.
window._lastUploadSizes = null;
updateReportTitleFromPath();
autoSetOutputDir(val);
updateSidebarSummary();
clearTimeout(historyTimer);
historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
if (previewTimer) clearTimeout(previewTimer);
previewTimer = setTimeout(loadPreview, 280);
suggestCoverageFile(val);
}
if (pathInput) {
pathInput.addEventListener("input", onPathChange);
}
if (outputDirInput) {
outputDirInput.addEventListener("input", function () {
outputDirInput.dataset.userEdited = "1";
syncStripOutputRoot();
updateReview();
});
}
[includeGlobsInput, excludeGlobsInput].forEach(function (node) {
if (!node) return;
node.addEventListener("input", function () {
updateReview();
if (previewTimer) clearTimeout(previewTimer);
previewTimer = setTimeout(loadPreview, 280);
});
});
["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
var node = document.getElementById(id);
if (node) node.addEventListener("change", updateReview);
});
if (reportTitleInput) {
reportTitleInput.addEventListener("input", function () {
reportTitleTouched = reportTitleInput.value.trim().length > 0;
updateReportTitleFromPath();
updateReview();
});
}
if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); updateSidebarSummary(); });
if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); updateSidebarSummary(); });
if (coverageInput) {
coverageInput.addEventListener("input", function () {
if (coverageInput.value.trim()) setCovStatus("idle");
});
}
if (form && loading && submitButton) {
form.addEventListener("submit", function (e) {
e.preventDefault();
submitButton.disabled = true;
submitButton.textContent = "Scanning...";
startAsyncAnalysis(new FormData(form));
});
}
function openPath(folder) {
if (!folder) return;
fetch('/open-path?path=' + encodeURIComponent(folder))
.then(function (r) { return r.json(); })
.then(function (d) {
if (d && d.server_mode_disabled)
showBannerToast(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
})
.catch(function () {});
}
Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
btn.addEventListener('click', function () {
openPath(btn.getAttribute('data-folder') || btn.dataset.folder || '');
});
});
// Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
if (wsOutputLink) {
wsOutputLink.addEventListener('click', function () {
openPath(wsOutputLink.dataset.folder || '');
});
}
loadSavedTheme();
updateMixedPolicyUI();
updatePythonDocstringUI();
applyScanPreset();
updatePresetDescriptions();
applyArtifactPreset();
updateReview();
updateScrollProgress(); // initialise bar to 0% (step 1)
window.addEventListener("scroll", updateScrollProgress, { passive: true });
onPathChange(); // seed output dir, history badge, and preview from initial path
updateStepNav(1);
// Restore step from URL hash on initial load (e.g., back-forward cache)
(function() {
var hashMatch = location.hash.match(/^#step([1-4])$/);
if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
})();
(function randomizeWatermarks() {
var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
if (!wms.length) return;
var placed = [];
function tooClose(top, left) {
for (var i = 0; i < placed.length; i++) {
var dt = Math.abs(placed[i][0] - top);
var dl = Math.abs(placed[i][1] - left);
if (dt < 16 && dl < 12) return true;
}
return false;
}
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];
}
var half = Math.floor(wms.length / 2);
wms.forEach(function (img, i) {
var pos = pick(i < half);
var size = Math.floor(Math.random() * 80 + 110);
var rot = (Math.random() * 360).toFixed(1);
var op = (Math.random() * 0.08 + 0.13).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;
});
})();
(function spawnCodeParticles() {
var container = document.getElementById('code-particles');
if (!container) return;
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'];
for (var i = 0; i < 38; 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);
}
})();
})();
</script>
<script nonce="{{ csp_nonce }}">
(function () {
var raw = {{ prefill_json|safe }};
if (!raw || typeof raw !== 'object' || !raw.path) return;
function setVal(id, val) { var el = document.getElementById(id); if (el) { el.value = val; if (id === 'output_dir') scrollInputToEnd(el); } }
function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
setVal('path', raw.path || '');
setVal('include_globs', raw.include_globs || '');
setVal('exclude_globs', raw.exclude_globs || '');
setVal('output_dir', raw.output_dir || '');
setVal('report_title', raw.report_title || '');
if (raw.submodule_breakdown) setChecked('submodule_breakdown', true);
setSelect('mixed_line_policy', raw.mixed_line_policy || 'code_only');
setChecked('python_docstrings_as_comments', !!raw.python_docstrings_as_comments);
setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
if (raw.include_lockfiles) setSelect('include_lockfiles', 'enabled');
setSelect('binary_file_behavior', raw.binary_file_behavior || 'skip');
setChecked('generate_html', raw.generate_html !== false);
setChecked('generate_pdf', !!raw.generate_pdf);
// Trigger dynamic UI updates after pre-fill.
setTimeout(function () {
var pathEl = document.getElementById('path');
if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
var policyEl = document.getElementById('mixed_line_policy');
if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
}, 80);
})();
</script>
<script nonce="{{ csp_nonce }}">
(function(){
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'}];
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);});}
try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
function init(){
var btn=document.getElementById('settings-btn');if(!btn)return;
var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
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>';
document.body.appendChild(m);
var g=document.getElementById('scheme-grid');
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);});
var cl=document.getElementById('settings-close');
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);
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');});
if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
}
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
}());
</script>
<div class="wb-ftip" id="wb-ftip" role="tooltip" aria-hidden="true">
<div class="wb-ftip-arrow"></div>
<span id="wb-ftip-text"></span>
</div>
<script nonce="{{ csp_nonce }}">(function(){
var tip=document.getElementById('wb-ftip');
var txt=document.getElementById('wb-ftip-text');
var arr=tip?tip.querySelector('.wb-ftip-arrow'):null;
if(!tip||!txt)return;
function pos(el){
var r=el.getBoundingClientRect();
tip.style.display='block';
var tw=tip.offsetWidth;
var lx=r.left+r.width/2-tw/2;
if(lx<8)lx=8;
if(lx+tw>window.innerWidth-8)lx=window.innerWidth-tw-8;
tip.style.left=lx+'px';
tip.style.top=(r.bottom+8)+'px';
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';}
}
document.querySelectorAll('[data-wb-tip]').forEach(function(el){
el.addEventListener('mouseenter',function(){txt.textContent=el.getAttribute('data-wb-tip');pos(el);});
el.addEventListener('mouseleave',function(){tip.style.display='none';});
});
window.addEventListener('blur',function(){tip.style.display='none';});
document.addEventListener('visibilitychange',function(){if(document.hidden)tip.style.display='none';});
})();
(function(){
function fixArtifactHintSpacing(){
var grid=document.querySelector('.artifact-grid');
if(grid){grid.style.setProperty('margin-bottom','48px','important');}
}
if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fixArtifactHintSpacing);}else{fixArtifactHintSpacing();}
}());
(function(){
var dot=document.getElementById('status-dot');
var pingEl=document.getElementById('server-ping-ms');
var tipEl=document.getElementById('server-tip-ping');
var fm=document.getElementById('footer-mode');
function setDotColor(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}
function doPing(){
var t0=performance.now();
fetch('/healthz',{cache:'no-store'})
.then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDotColor(ms);})
.catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});
}
doPing();
setInterval(doPing,5000);
if(fm){var isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';fm.textContent='oxide-sloc v{{ version }} — Mode: '+(isServer?'Network Server':'Local');}
})();
</script>
<span id="page-bottom" aria-hidden="true" style="display:block;height:0;"></span>
<footer class="site-footer">
local code analysis - metrics, history and reports
· <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: {% if server_mode %}Network Server{% else %}Local{% endif %}</em>
· Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
· <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
· <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
· <a href="/api-docs" rel="noopener">REST API</a>
</footer>
</body>
</html>
"##,
ext = "html"
)]
struct IndexTemplate {
version: &'static str,
prefill_json: String,
csp_nonce: String,
git_repo: String,
git_ref: String,
git_label_json: String,
git_output_dir_json: String,
server_mode: bool,
}
// ── SplashTemplate ────────────────────────────────────────────────────────────
#[derive(Template)]
#[template(
source = r##"
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OxideSLOC — local code analysis - metrics, history and reports</title>
<link rel="icon" type="image/png" href="/images/logo/small-logo.png">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "oxide-sloc",
"applicationCategory": "DeveloperApplication",
"operatingSystem": "Windows, Linux",
"description": "IEEE 1045-1992 SLOC analysis workbench — CLI, web UI, MCP server, 60 languages, offline-first. Counts code, comment, and blank lines; detects unit tests; produces HTML and PDF reports.",
"softwareVersion": "{{ version }}",
"author": { "@type": "Person", "name": "Nima Shafie", "url": "https://github.com/NimaShafie" },
"license": "https://www.gnu.org/licenses/agpl-3.0.html",
"url": "https://github.com/oxide-sloc/oxide-sloc",
"downloadUrl": "https://github.com/oxide-sloc/oxide-sloc/releases",
"featureList": "60 language analysis, IEEE 1045-1992 SLOC counting, HTML and PDF reports, REST API, MCP server, CI/CD integration, trend reports, test metrics, git integration",
"programmingLanguage": "Rust",
"keywords": "sloc, code analysis, source lines of code, metrics, MCP, AI agent"
}
</script>
<style nonce="{{ csp_nonce }}">
:root {
--radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
--line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
--nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
--oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
--shadow-strong:0 28px 56px rgba(77,44,20,0.20);
}
body.dark-theme {
--bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
--text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
}
*{box-sizing:border-box;} html,body{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);} body{display:flex;flex-direction:column;}
.background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
.background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
.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;}
@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));}}
.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);}
.top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
.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));}
.brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
.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;}
.nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
@media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
@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; } }
.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;}
a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
.theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
.theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
.theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
.theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
.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;}
.settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
.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);}
.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;}
.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;}
.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;}
.scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
.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;}
.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);}
.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;}
.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;}
.tz-select:focus{border-color:var(--oxide);}
.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;}
.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;}
.page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 12px;position:relative;z-index:1;}
@media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
.hero{text-align:center;margin:0 auto 18px;}
.hero-logo-wrap{display:inline-block;cursor:default;}
.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;}
.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;}
.hero-title-wrap{position:relative;display:inline-flex;flex-direction:column;align-items:center;}
.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;}
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%);}
.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;
background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
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;}
@keyframes titleReveal{to{clip-path:inset(0 0% 0 0);}}
@keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
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;}
.hero-subtitle{font-size:15px;color:var(--muted);line-height:1.55;max-width:600px;margin:0 auto;min-height:3.2em;opacity:0;}
.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;}
@keyframes cursorBlink{0%,100%{opacity:1;}50%{opacity:0;}}
.card-sections{display:flex;flex-direction:column;gap:25px;margin:0 0 16px;}
.card-section-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;padding-left:2px;}
.card-section-grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
.card-section-grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;}
@media(max-width:900px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr 1fr;}}
@media(max-width:480px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr;}}
.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;}
.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;}
@keyframes cardRise{from{opacity:0;}to{opacity:1;}}
@media(prefers-reduced-motion:reduce){.action-card,.lan-card{animation:none;}}
.action-card:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
.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);}
.action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
.action-card-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
.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);}
.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);}
.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);}
.action-card-title{font-size:15px;font-weight:850;letter-spacing:-0.02em;margin:0 0 4px;}
.action-card-desc{font-size:12px;color:var(--muted);line-height:1.55;margin:0 0 10px;flex:1;}
.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;}
body.dark-theme .action-card-cta{color:var(--oxide);}
.action-card.view .action-card-cta{color:var(--accent-2);}
body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
.action-card.compare .action-card-cta{color:#7c3aed;}
body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
.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);}
.action-card.git-tools .action-card-cta{color:#15803d;}
body.dark-theme .action-card.git-tools .action-card-cta{color:#4ade80;}
.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);}
.action-card.trend .action-card-cta{color:#0e7490;}
body.dark-theme .action-card.trend .action-card-cta{color:#22d3ee;}
.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);}
.action-card.automation .action-card-cta{color:#b45309;}
body.dark-theme .action-card.automation .action-card-cta{color:#fbbf24;}
.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);}
.action-card.test-metrics .action-card-cta{color:#be185d;}
body.dark-theme .action-card.test-metrics .action-card-cta{color:#f472b6;}
.action-card:hover .action-card-cta{gap:12px;}
.action-card.card-split{flex-direction:row;align-items:stretch;}
.action-card-left{flex:1;display:flex;flex-direction:column;align-items:flex-start;}
.action-card-sep{width:1px;background:var(--line);margin:0 12px;opacity:0.22;align-self:stretch;flex-shrink:0;}
.action-card-right{width:170px;display:flex;flex-direction:column;justify-content:center;gap:10px;flex-shrink:0;}
.ac-right-row{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:var(--muted);}
.ac-right-row svg{width:14px;height:14px;stroke:var(--oxide);stroke-width:2;fill:none;flex-shrink:0;}
.ac-right-stat{font-size:11px;color:var(--oxide);font-weight:700;margin-top:4px;min-height:14px;}
.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;}
.ac-badge.active{opacity:1;}
.ac-badge.github{border-color:#555;color:#555;}
.ac-badge.gitlab{border-color:#e24329;color:#e24329;}
.ac-badge.bitbucket{border-color:#2684ff;color:#2684ff;}
.ac-badge.confluence{border-color:#0052cc;color:#0052cc;}
.ac-badges-grid{display:flex;flex-wrap:wrap;gap:5px;}
body.dark-theme .ac-right-row{color:var(--muted);}
body.dark-theme .ac-badge.github{border-color:#aaa;color:#aaa;}
@media(max-width:600px){.action-card-sep,.action-card-right{display:none;}}
.divider{height:1px;background:var(--line);margin:32px 0;}
.info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin-bottom:23px;}
@media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
@media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
.info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:9px 12px;text-align:center;position:relative;cursor:default;
transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
.info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
.info-chip-val{font-size:15px;font-weight:900;color:var(--oxide);}
body.dark-theme .info-chip-val{color:var(--oxide);}
.info-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:2px;}
.info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
.info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
border:6px solid transparent;border-top-color:var(--text);}
.info-chip:hover .info-chip-tip{display:block;}
.chip-slide{transition:filter 0.70s ease,opacity 0.70s ease;}
.chip-slide.fading{filter:blur(5px);opacity:0;}
.site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
.site-footer a{color:var(--muted);}
.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;}
.lan-card.server{border-color:#3b82f6;background:linear-gradient(135deg,rgba(59,130,246,0.06),var(--surface));}
body.dark-theme .lan-card.server{background:linear-gradient(135deg,rgba(59,130,246,0.10),var(--surface));}
.lan-card-header{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:800;margin-bottom:16px;letter-spacing:-0.01em;}
.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;}
.lan-badge.local{background:var(--oxide-2);}
.lan-url-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
.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);}
body.dark-theme .lan-url{color:#93c5fd;background:rgba(59,130,246,0.14);border-color:rgba(59,130,246,0.28);}
.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;}
.lan-copy-btn:hover{background:rgba(59,130,246,0.10);border-color:#3b82f6;color:#2563eb;}
.lan-hint{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:12px;}
.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;}
body.dark-theme .lan-auth-row{background:rgba(255,255,255,0.04);}
.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;}
.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);}
body.dark-theme .lan-local-hint{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}
body.dark-theme .lan-local-hint code{background:rgba(255,255,255,0.06);}
.lan-local-hint strong{color:var(--muted);font-weight:600;margin-right:2px;}
.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;}
@media (max-height: 1100px) {
.page{padding-top:10px;}
.hero{margin-bottom:10px;}
.hero-logo{width:54px;height:60px;}
.hero-logo-shadow{width:42px;}
.hero-title{font-size:28px;}
.hero-subtitle{font-size:13px;}
.card-sections{gap:12px;margin-bottom:6px;}
.card-section-grid-2,.card-section-grid-3{gap:10px;}
.action-card{padding:8px 15px 8px;}
.action-card-icon{width:34px;height:34px;border-radius:10px;margin-bottom:6px;}
.action-card-icon svg{width:18px;height:18px;}
.action-card-title{font-size:13px;}
.action-card-desc{font-size:11px;margin-bottom:6px;}
.action-card-cta{font-size:11px;}
.ac-right-row{font-size:11px;}
.divider{margin:14px 0;}
.info-strip{gap:7px;margin-bottom:8px;}
.info-chip{padding:7px 10px;}
.info-chip-val{font-size:13px;}
.info-chip-label{font-size:9px;}
.site-footer{padding:8px 24px;font-size:12px;}
.lan-local-hint{margin-top:8px;}
}
@media (max-height: 850px) {
.page{padding-top:6px;}
.hero{margin-bottom:6px;}
.hero-logo{width:42px;height:46px;}
.hero-title{font-size:22px;}
.hero-subtitle{font-size:12px;}
.card-sections{gap:10px;}
.action-card-desc{margin-bottom:4px;}
.divider{margin:8px 0;}
.info-strip{margin-bottom:6px;}
.lan-local-hint{margin-top:10px;}
}
</style>
</head>
<body>
<div class="background-watermarks" aria-hidden="true">
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
</div>
<div class="code-particles" id="code-particles" aria-hidden="true"></div>
<div class="top-nav">
<div class="top-nav-inner">
<a class="brand" href="/">
<img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
<div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
</a>
<div class="nav-right">
<a class="nav-pill" href="/">Home</a>
<div class="nav-dropdown">
<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>
<div class="nav-dropdown-menu">
<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>
</div>
</div>
<a class="nav-pill" href="/compare-scans">Compare Scans</a>
<a class="nav-pill" href="/test-metrics">Test Metrics</a>
<div class="nav-dropdown">
<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>
<div class="nav-dropdown-menu">
<a href="/integrations"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
</div>
</div>
<div class="server-status-wrap" id="server-status-wrap">
<div class="nav-pill server-online-pill" id="server-status-pill">
<span class="status-dot" id="status-dot"></span>
<span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
<span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
</div>
<div class="server-status-tip">
{% if server_mode %}OxideSLOC is running in server mode — accessible on your LAN.{% else %}OxideSLOC is running locally — only accessible from this machine.{% endif %}
<span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
</div>
</div>
<button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
<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>
</button>
<button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
<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>
<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>
</button>
</div>
</div>
</div>
<div class="page">
<div class="hero">
<div class="hero-logo-wrap" id="hero-logo-wrap">
<img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
</div>
<div class="hero-logo-shadow"></div>
<div class="hero-title-wrap">
<div class="hero-title-aura" aria-hidden="true"></div>
<h1 class="hero-title" id="hero-title">OxideSLOC</h1>
</div>
<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>
</div>
<div class="card-sections">
<div>
<div class="card-section-label">Analysis</div>
<div class="card-section-grid-2">
<a class="action-card scan card-split" href="/scan-setup">
<div class="action-card-left">
<div class="action-card-icon">
<svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
</div>
<div class="action-card-title">Scan Project</div>
<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>
<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>
</div>
<div class="action-card-sep"></div>
<div class="action-card-right">
<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>
<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>
<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>
<div class="ac-right-stat" id="acp-scan-stat"></div>
</div>
</a>
<a class="action-card test-metrics card-split" href="/test-metrics">
<div class="action-card-left">
<div class="action-card-icon">
<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>
</div>
<div class="action-card-title">Test Metrics</div>
<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>
<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>
</div>
<div class="action-card-sep"></div>
<div class="action-card-right">
<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>
<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>
<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>
<div class="ac-right-stat" id="acp-test-stat"></div>
</div>
</a>
</div>
</div>
<div>
<div class="card-section-label">Reports & Insights</div>
<div class="card-section-grid-3">
<a class="action-card view" href="/view-reports">
<div class="action-card-icon">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
</div>
<div class="action-card-title">View Reports</div>
<p class="action-card-desc">Browse recorded scans, open HTML reports, and review historical metrics — code, comments, blank lines, and git branch info.</p>
<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>
</a>
<a class="action-card compare" href="/compare-scans">
<div class="action-card-icon">
<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>
</div>
<div class="action-card-title">Compare Scans</div>
<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>
<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>
</a>
<a class="action-card trend" href="/trend-reports">
<div class="action-card-icon">
<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>
</div>
<div class="action-card-title">Trend Report</div>
<p class="action-card-desc">Visualize how SLOC, comments, and blank lines evolve over time. Spot regressions and chart the full scan history.</p>
<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>
</a>
</div>
</div>
<div>
<div class="card-section-label">Developer Tools</div>
<div class="card-section-grid-2">
<a class="action-card git-tools card-split" href="/git-browser">
<div class="action-card-left">
<div class="action-card-icon">
<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>
</div>
<div class="action-card-title">Git Browser</div>
<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>
<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>
</div>
<div class="action-card-sep"></div>
<div class="action-card-right">
<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>
<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>
<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>
</div>
</a>
<a class="action-card automation card-split" href="/integrations">
<div class="action-card-left">
<div class="action-card-icon">
<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>
</div>
<div class="action-card-title">Integrations</div>
<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>
<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>
</div>
<div class="action-card-sep"></div>
<div class="action-card-right">
<div class="ac-badges-grid">
<span class="ac-badge github" id="acp-gh">GitHub</span>
<span class="ac-badge gitlab" id="acp-gl">GitLab</span>
<span class="ac-badge bitbucket" id="acp-bb">Bitbucket</span>
<span class="ac-badge confluence" id="acp-cf">Confluence</span>
</div>
<div class="ac-right-stat" id="acp-int-stat"></div>
</div>
</a>
</div>
</div>
</div>
{% if server_mode %}
<div class="lan-card server">
<div class="lan-card-header">
<span class="lan-badge">LAN server</span>
Accessible on your network
</div>
{% if let Some(ip) = lan_ip %}
<div class="lan-url-row">
<code class="lan-url" id="lan-url-val">http://{{ ip }}:{{ port }}</code>
<button class="lan-copy-btn" id="lan-copy-btn" title="Copy URL">
<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>
Copy URL
</button>
</div>
<p class="lan-hint">Share this address with anyone on the same network.{% if has_api_key %} Authentication: enabled.{% else %} Authentication: not configured — all endpoints are open.{% endif %}</p>
{% if has_api_key %}
<div class="lan-auth-row">curl -H "Authorization: Bearer $SLOC_API_KEY" http://{{ ip }}:{{ port }}/healthz</div>
{% endif %}
{% else %}
<p class="lan-hint">Could not auto-detect your LAN IP. Find it with <code>hostname -I</code> (Linux) or <code>ipconfig</code> (Windows), then open <code>http://<your-ip>:{{ port }}</code>.{% if has_api_key %} Authentication: enabled.{% else %} Authentication: not configured.{% endif %}</p>
{% endif %}
</div>
{% endif %}
<div class="divider"></div>
<div class="info-strip">
<div class="info-chip">
<div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 48 more</div>
<div class="chip-slide">
<div class="info-chip-val">60</div>
<div class="info-chip-label">Languages</div>
</div>
</div>
<div class="info-chip">
<div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
<div class="chip-slide">
<div class="info-chip-val">100%</div>
<div class="info-chip-label">Self-contained</div>
</div>
</div>
<div class="info-chip">
<div class="info-chip-tip">Self-contained HTML reports with light/dark theme<br>— shareable without a server. PDF via headless Chromium (CLI).</div>
<div class="chip-slide">
<div class="info-chip-val">HTML+PDF</div>
<div class="info-chip-label">Exportable reports</div>
</div>
</div>
<div class="info-chip">
<div class="info-chip-tip">GitHub, GitLab, and Bitbucket push events<br>trigger scans automatically via webhook</div>
<div class="chip-slide">
<div class="info-chip-val">Webhook</div>
<div class="info-chip-label">3 platforms</div>
</div>
</div>
<div class="info-chip">
<div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
<div class="chip-slide">
<div class="info-chip-val">IEEE</div>
<div class="info-chip-label">1045-1992</div>
</div>
</div>
</div>
{% if lan_ip.is_none() %}
<div class="lan-local-hint">
<strong>Want teammates on the same network to access this?</strong><br>
Relaunch in server mode: <code>oxide-sloc serve --server</code> or <code>bash scripts/serve-server.sh</code>
</div>
{% endif %}
</div>
<footer class="site-footer">
local code analysis - metrics, history and reports
· <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
· Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
· <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
· <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
· <a href="/api-docs" rel="noopener">REST API</a>
</footer>
<script nonce="{{ csp_nonce }}">
(function () {
var storageKey = 'oxide-sloc-theme';
var body = document.body;
try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
var toggle = document.getElementById('theme-toggle');
if (toggle) toggle.addEventListener('click', function () {
var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
body.classList.toggle('dark-theme', next === 'dark');
try { localStorage.setItem(storageKey, next); } catch(e) {}
});
var copyBtn = document.getElementById('lan-copy-btn');
if (copyBtn) copyBtn.addEventListener('click', function() {
var btn = this;
var el = document.getElementById('lan-url-val');
if (!el) return;
var url = el.textContent.trim();
if (navigator.clipboard) {
navigator.clipboard.writeText(url).then(function() {
var orig = btn.innerHTML;
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!';
setTimeout(function() { btn.innerHTML = orig; }, 1800);
});
}
});
(function randomizeWatermarks() {
var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
if (!wms.length) return;
var placed = [];
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;
}
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];
}
var half = Math.floor(wms.length / 2);
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;
});
})();
(function spawnCodeParticles() {
var container = document.getElementById('code-particles');
if (!container) return;
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'
];
var count = 38;
for (var i = 0; i < count; i++) {
(function(idx) {
var el = document.createElement('span');
el.className = 'code-particle';
var text = snippets[idx % snippets.length];
el.textContent = text;
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)+'%';
+ '--rot:' + rot + 'deg;--op:' + op + ';'
+ 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
container.appendChild(el);
})(i);
}
})();
(function heroAnimations() {
var sub = document.getElementById('hero-subtitle');
if (sub) {
var full = sub.textContent.trim();
sub.textContent = '';
sub.style.opacity = '1';
var cursor = document.createElement('span');
cursor.className = 'hero-cursor';
sub.appendChild(cursor);
var i = 0;
setTimeout(function() {
var iv = setInterval(function() {
if (i < full.length) {
sub.insertBefore(document.createTextNode(full[i]), cursor);
i++;
} else {
clearInterval(iv);
setTimeout(function() {
cursor.style.transition = 'opacity 1s ease';
cursor.style.opacity = '0';
setTimeout(function() { if (cursor.parentNode) cursor.parentNode.removeChild(cursor); }, 1000);
}, 2400);
}
}, 11);
}, 374);
}
})();
(function logoBob() {
var logo = document.querySelector('.hero-logo');
var shadow = document.querySelector('.hero-logo-shadow');
if (!logo) return;
var cycleStart = null, cycleDur = 3600;
var peakY = -14, peakScale = 1.07, peakRot = 0;
function newCycle() {
cycleDur = 3000 + Math.random() * 1840;
peakY = -(9 + Math.random() * 13.8);
peakScale = 1.04 + Math.random() * 0.081;
peakRot = (Math.random() * 11.5 - 5.75);
}
function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
newCycle();
function frame(ts) {
if (cycleStart === null) cycleStart = ts;
var t = (ts - cycleStart) / cycleDur;
if (t >= 1) { cycleStart = ts; t = 0; newCycle(); }
var phase = t < 0.4 ? ease(t / 0.4) : t < 0.6 ? 1 : ease(1 - (t - 0.6) / 0.4);
var y = peakY * phase;
var sc = 1 + (peakScale - 1) * phase;
var rot = peakRot * Math.sin(Math.PI * phase);
logo.style.transform = 'translateY('+y.toFixed(2)+'px) scale('+sc.toFixed(4)+') rotate('+rot.toFixed(2)+'deg)';
if (shadow) {
shadow.style.transform = 'scaleX('+(1 - 0.3*phase).toFixed(4)+')';
shadow.style.opacity = (0.55 - 0.37*phase).toFixed(3);
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
})();
(function mouseEffects() {
var heroTitle = document.getElementById('hero-title');
var raf = null, mx = window.innerWidth / 2, my = window.innerHeight / 2;
function tick() {
raf = null;
if (heroTitle) {
var r = heroTitle.getBoundingClientRect();
var dx = (mx - (r.left + r.width / 2)) / (window.innerWidth / 2);
var dy = (my - (r.top + r.height / 2)) / (window.innerHeight / 2);
heroTitle.style.transform = 'perspective(800px) rotateX('+(-dy*7.8).toFixed(2)+'deg) rotateY('+(dx*18.2).toFixed(2)+'deg)';
}
}
document.addEventListener('mousemove', function(e) {
mx = e.clientX; my = e.clientY;
if (!raf) raf = requestAnimationFrame(tick);
});
document.addEventListener('mouseleave', function() {
if (heroTitle) {
heroTitle.style.transition = 'transform 0.5s ease';
heroTitle.style.transform = '';
setTimeout(function() { heroTitle.style.transition = ''; }, 500);
}
});
document.querySelectorAll('.action-card').forEach(function(card) {
card.addEventListener('mousemove', function(e) {
var rect = card.getBoundingClientRect();
var dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2);
var dy = (e.clientY - (rect.top + rect.height / 2)) / (rect.height / 2);
card.style.transition = 'transform 0.08s linear,box-shadow 0.18s ease,border-color 0.18s ease';
card.style.transform = 'perspective(700px) rotateX('+(-dy*4.2).toFixed(2)+'deg) rotateY('+(dx*4.2).toFixed(2)+'deg) translateY(-5px) scale(1.03)';
});
card.addEventListener('mouseleave', function() {
card.style.transition = '';
card.style.transform = '';
});
});
})();
(function chipSlideshow() {
var slides = [
[{v:'60',l:'Languages'},{v:'Rust · Go · Python',l:'and 57 more'},{v:'C · Java · TypeScript',l:'Swift · Kotlin · Zig'}],
[{v:'100%',l:'Self-contained'},{v:'Zero',l:'Dependencies'},{v:'Single',l:'Binary'}],
[{v:'HTML+PDF',l:'Exportable reports'},{v:'Light+Dark',l:'Themed'},{v:'Offline',l:'No server needed'}],
[{v:'Webhook',l:'3 platforms'},{v:'GitHub + GitLab',l:'+ Bitbucket'},{v:'Auto-scan',l:'On every push'}],
[{v:'IEEE',l:'1045-1992'},{v:'Physical',l:'SLOC standard'},{v:'Blank lines',l:'Configurable'}]
];
var chips = Array.prototype.slice.call(document.querySelectorAll('.info-chip'));
var indices = [0,0,0,0,0];
var paused = [false,false,false,false,false];
chips.forEach(function(chip, i) {
chip.addEventListener('mouseenter', function() { paused[i] = true; });
chip.addEventListener('mouseleave', function() { paused[i] = false; });
});
function advance(i) {
if (paused[i]) return;
var chip = chips[i];
var inner = chip.querySelector('.chip-slide');
if (!inner) return;
inner.classList.add('fading');
setTimeout(function() {
indices[i] = (indices[i] + 1) % slides[i].length;
var s = slides[i][indices[i]];
chip.querySelector('.info-chip-val').textContent = s.v;
chip.querySelector('.info-chip-label').textContent = s.l;
inner.classList.remove('fading');
}, 720);
}
setInterval(function() {
chips.forEach(function(chip, i) { advance(i); });
}, 6000);
})();
(function cardLiveData() {
fetch('/api/project-history').then(function(r){return r.json();}).then(function(d){
var el = document.getElementById('acp-scan-stat');
if(el && d.scan_count) el.textContent = d.scan_count + ' scan' + (d.scan_count === 1 ? '' : 's') + ' in history';
}).catch(function(){});
fetch('/api/metrics/latest').then(function(r){return r.ok ? r.json() : null;}).then(function(d){
var el = document.getElementById('acp-test-stat');
if(el && d && d.summary && d.summary.test_count) el.textContent = fmt(d.summary.test_count) + ' tests in last scan';
}).catch(function(){});
fetch('/api/schedules').then(function(r){return r.json();}).then(function(d){
var sc = (d.schedules || []).filter(function(s){return s.enabled !== false;});
var providers = sc.map(function(s){return (s.provider || '').toLowerCase();});
if(providers.indexOf('github') >= 0) { var e = document.getElementById('acp-gh'); if(e) e.classList.add('active'); }
if(providers.indexOf('gitlab') >= 0) { var e = document.getElementById('acp-gl'); if(e) e.classList.add('active'); }
if(providers.indexOf('bitbucket') >= 0) { var e = document.getElementById('acp-bb'); if(e) e.classList.add('active'); }
var stat = document.getElementById('acp-int-stat');
if(stat && sc.length) stat.textContent = sc.length + ' webhook' + (sc.length === 1 ? '' : 's') + ' configured';
}).catch(function(){});
fetch('/api/confluence/config').then(function(r){return r.json();}).then(function(d){
if(d.configured) { var e = document.getElementById('acp-cf'); if(e) e.classList.add('active'); }
}).catch(function(){});
})();
})();
</script>
<script nonce="{{ csp_nonce }}">
(function(){
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'}];
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);});}
try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
function init(){
var btn=document.getElementById('settings-btn');if(!btn)return;
var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
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>';
document.body.appendChild(m);
var g=document.getElementById('scheme-grid');
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);});
var cl=document.getElementById('settings-close');
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);
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');});
if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
}
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
}());
</script>
<script nonce="{{ csp_nonce }}">(function(){var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';if(lbl&&lbl.textContent==='Server')lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{{ version }} — Mode: '+(isServer?'Network Server':'Local');function setDot(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}function doPing(){var t0=performance.now();fetch('/healthz',{cache:'no-store'}).then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}).catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});}doPing();setInterval(doPing,5000);})();</script>
</body>
</html>
"##,
ext = "html"
)]
struct SplashTemplate {
csp_nonce: String,
server_mode: bool,
lan_ip: Option<String>,
port: u16,
version: &'static str,
has_api_key: bool,
}
// ── ScanSetupTemplate ─────────────────────────────────────────────────────────
#[derive(Template)]
#[template(
source = r##"
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OxideSLOC — Start a Scan</title>
<link rel="icon" type="image/png" href="/images/logo/small-logo.png">
<style nonce="{{ csp_nonce }}">
:root {
--radius:18px; --bg:#f5efe8; --surface:#ffffff; --surface-2:#fbf7f2;
--line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
--nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
--oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
--shadow-strong:0 28px 56px rgba(77,44,20,0.20);
}
body.dark-theme {
--bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
--text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
}
*{box-sizing:border-box;} html,body{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);} body{display:flex;flex-direction:column;}
.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);}
.top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
.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));}
.brand-copy{display:flex;flex-direction:column;justify-content:center;}
.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;}
.nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
@media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
@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; } }
.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;}
a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
.theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
.theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
.theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
.theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
.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;}
.settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
.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);}
.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;}
.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;}
.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;}
.scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
.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;}
.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);}
.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;}
.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;}
.tz-select:focus{border-color:var(--oxide);}
.page{max-width:1104px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
.page-header{text-align:center;margin-bottom:16px;}
.page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
.page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
/* Cards */
.option-grid{display:flex;flex-direction:column;gap:16px;padding-top:16px;}
.option-card-wrap{position:relative;}
.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;}
.option-card:hover{transform:translateY(-5px) scale(1.03);border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
@keyframes cardRise{from{opacity:0;}to{opacity:1;}}
@media(prefers-reduced-motion:reduce){.option-card{animation:none;}}
.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;}
.option-icon{transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
.option-card:hover .option-icon{transform:rotate(-8deg) scale(1.12);}
#recent-card{flex-direction:column;align-items:stretch;gap:0;}
.card-top-row{display:flex;align-items:center;gap:20px;}
/* Two-column layout inside each card */
.card-body{flex:1;min-width:0;display:grid;grid-template-columns:1fr 220px;gap:20px;align-items:center;padding-left:12px;}
.card-left{display:flex;align-items:flex-start;min-width:0;}
.option-icon{width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
.option-icon svg{width:28px;height:28px;stroke:#fff;fill:none;stroke-width:2;}
.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);}
.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);}
.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);}
.card-text{min-width:0;}
.option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 9px;}
.option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
.feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
.feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
.feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
/* Right CTA column */
.card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
.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;}
/* Re-scan count badge */
.rescan-count-box{text-align:center;padding:12px 10px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;}
.rescan-count-num{font-size:28px;font-weight:900;color:var(--oxide);line-height:1;}
.rescan-count-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-top:5px;}
body.dark-theme .rescan-count-box{background:var(--surface-2);border-color:var(--line-strong);}
.btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
.btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
.btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
body.dark-theme .btn-secondary{color:var(--oxide);}
.btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
.card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
/* File input overlay — must be full-width so it aligns with other card-right buttons */
.file-input-wrap{position:relative;width:100%;}
.file-input-wrap .btn{width:100%;}
.file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
.background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
.background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
.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;}
@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));}}
/* Recent list (card 3 — full-width section below header) */
.section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
.recent-list{display:flex;flex-direction:column;gap:8px;}
.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;}
.recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
.recent-item-info{flex:1;min-width:0;}
.recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
.recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
.no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
.site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
.site-footer a{color:var(--muted);}
@media(max-width:680px){
.card-body{grid-template-columns:1fr;}
.card-right{flex-direction:row;flex-wrap:wrap;}
.btn{flex:1;}
}
.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;}
.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;}
.server-status-wrap{position:relative;display:inline-flex;}.server-online-pill{cursor:default;}.server-status-tip{visibility:hidden;opacity:0;pointer-events:none;position:absolute;top:calc(100% + 10px);right:0;z-index:100;background:rgba(20,12,8,0.97);color:rgba(255,255,255,0.92);border-radius:10px;padding:10px 14px;font-size:12px;font-weight:500;line-height:1.55;white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.32);border:1px solid rgba(255,255,255,0.10);transition:opacity 0.15s ease;}.server-status-tip::before{content:'';position:absolute;bottom:100%;right:18px;border:6px solid transparent;border-bottom-color:rgba(20,12,8,0.97);}.server-status-wrap:hover .server-status-tip{visibility:visible;opacity:1;pointer-events:auto;}
</style>
</head>
<body>
<div class="background-watermarks" aria-hidden="true">
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
</div>
<div class="code-particles" id="code-particles" aria-hidden="true"></div>
<div class="top-nav">
<div class="top-nav-inner">
<a class="brand" href="/">
<img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
<div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
</a>
<div class="nav-right">
<a class="nav-pill" href="/">Home</a>
<div class="nav-dropdown">
<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>
<div class="nav-dropdown-menu">
<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>
</div>
</div>
<a class="nav-pill" href="/compare-scans">Compare Scans</a>
<a class="nav-pill" href="/test-metrics">Test Metrics</a>
<div class="nav-dropdown">
<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>
<div class="nav-dropdown-menu">
<a href="/integrations"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
</div>
</div>
<div class="server-status-wrap" id="server-status-wrap">
<div class="nav-pill server-online-pill" id="server-status-pill">
<span class="status-dot" id="status-dot"></span>
<span id="server-status-label">Server</span>
<span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
</div>
<div class="server-status-tip">
OxideSLOC is running — accessible on your network.
<span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
</div>
</div>
<button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
<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>
</button>
<button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
<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>
<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>
</button>
</div>
</div>
</div>
<div class="page">
<div class="page-header">
<h1>How would you like to scan?</h1>
<p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
</div>
<div class="option-grid">
<!-- Option 1: New scan -->
<div class="option-card-wrap">
<div class="option-card">
<div class="option-icon new-scan">
<svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
</div>
<div class="card-body">
<div class="card-left">
<div class="card-text">
<div class="option-title">Start a new scan</div>
<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>
<ul class="feature-list">
<li>Live project scope preview before you run</li>
<li>4 IEEE 1045-1992 counting modes with interactive examples</li>
<li>HTML, PDF, and JSON output — your choice</li>
</ul>
</div>
</div>
<div class="card-right">
<a class="btn btn-primary" href="/scan">
Configure & scan
<svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
</a>
<p class="card-tip">Full 4-step setup · all options</p>
</div>
</div>
</div>
</div>
<!-- Option 2: Load from config file -->
<div class="option-card-wrap">
<div class="option-card">
<div class="option-icon load-config">
<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>
</div>
<div class="card-body">
<div class="card-left">
<div class="card-text">
<div class="option-title">Load a saved config</div>
<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>
<ul class="feature-list">
<li>All 15 settings restored from the file</li>
<li>Fully editable — change path or output dir</li>
<li>Works with any scan-config.json</li>
</ul>
</div>
</div>
<div class="card-right">
<div class="file-input-wrap">
<button class="btn btn-secondary" id="load-config-btn" type="button">
<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>
Choose config file
</button>
<input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
</div>
<p class="card-tip" id="config-file-name">Exported after every scan</p>
</div>
</div>
</div>
</div>
<!-- Option 3: Re-scan recent project -->
<div class="option-card-wrap">
<div class="option-card" id="recent-card">
<div class="card-top-row">
<div class="option-icon rescan">
<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>
</div>
<div class="card-body">
<div class="card-left">
<div class="card-text">
<div class="option-title">Re-scan a recent project</div>
<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>
<ul class="feature-list">
<li>All 15+ settings restored from the saved config</li>
<li>Path and output dir are editable before running</li>
<li>Only scans with a saved config appear here</li>
</ul>
</div>
</div>
<div class="card-right">
<div class="rescan-count-box">
<div class="rescan-count-num" id="rescan-count-num">—</div>
<div class="rescan-count-label">saved configs</div>
</div>
<a class="btn btn-secondary" href="/view-reports">
<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>
View all runs
</a>
<p class="card-tip">Opens run history</p>
</div>
</div>
</div>
<div class="section-divider"></div>
<div class="recent-list" id="recent-list">
<p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
</div>
</div>
</div>
</div>
</div>
<footer class="site-footer">
local code analysis - metrics, history and reports
· <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
· Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
· <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
· <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
· <a href="/api-docs" rel="noopener">REST API</a>
</footer>
<script nonce="{{ csp_nonce }}">
(function () {
var storageKey = 'oxide-sloc-theme';
var body = document.body;
try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
var toggle = document.getElementById('theme-toggle');
if (toggle) toggle.addEventListener('click', function () {
var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
body.classList.toggle('dark-theme', next === 'dark');
try { localStorage.setItem(storageKey, next); } catch(e) {}
});
(function randomizeWatermarks() {
var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
if (!wms.length) return;
var placed = [];
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; }
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]; }
var half = Math.floor(wms.length / 2);
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; });
})();
(function spawnCodeParticles() {
var container = document.getElementById('code-particles');
if (!container) return;
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'];
var count = 38;
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); }
})();
// Recent scans data injected from server
var recentScans = {{ recent_scans_json|safe }};
function configToParams(cfg) {
var p = new URLSearchParams();
p.set('prefilled', '1');
if (cfg.path) p.set('path', cfg.path);
if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
if (cfg.report_title) p.set('report_title', cfg.report_title);
p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
if (cfg.generate_pdf) p.set('generate_pdf', 'on');
return p;
}
// Build recent scan list (capped at 3 visible entries)
var list = document.getElementById('recent-list');
var noNote = document.getElementById('no-recent-note');
var hasAny = false;
var MAX_RECENT = 3;
if (Array.isArray(recentScans)) {
var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
var shown = 0;
validEntries.forEach(function (entry) {
if (shown >= MAX_RECENT) return;
shown++;
hasAny = true;
var item = document.createElement('div');
item.className = 'recent-item';
item.title = 'Restore all settings and open wizard';
item.innerHTML =
'<div class="recent-item-info">' +
'<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
'<div class="recent-item-meta">' + escHtml(entry.path || '') + ' · ' + escHtml(entry.timestamp || '') + '</div>' +
'</div>' +
'<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
item.addEventListener('click', function () {
var params = configToParams(entry.config);
window.location.href = '/scan?' + params.toString();
});
list.appendChild(item);
});
if (validEntries.length > MAX_RECENT) {
var moreEl = document.createElement('div');
moreEl.className = 'recent-more-link';
moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more — <a href="/view-reports">view all runs</a>';
list.appendChild(moreEl);
}
}
if (hasAny && noNote) noNote.style.display = 'none';
// Update count badge
var countEl = document.getElementById('rescan-count-num');
if (countEl) {
var total = Array.isArray(recentScans) ? recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; }).length : 0;
countEl.textContent = total > 0 ? total : '0';
}
// Config file loader
var fileInput = document.getElementById('config-file-input');
var fileName = document.getElementById('config-file-name');
var loadBtn = document.getElementById('load-config-btn');
// Wire the visible button to open the hidden file picker.
if (loadBtn && fileInput) {
loadBtn.addEventListener('click', function () { fileInput.click(); });
}
if (fileInput) {
fileInput.addEventListener('change', function () {
var file = fileInput.files && fileInput.files[0];
if (!file) return;
if (fileName) fileName.textContent = '✓ ' + file.name;
var reader = new FileReader();
reader.onload = function (e) {
try {
var cfg = JSON.parse(e.target.result);
if (!cfg || typeof cfg !== 'object') { alert('Invalid config file — expected a JSON object.'); return; }
var params = configToParams(cfg);
window.location.href = '/scan?' + params.toString();
} catch (err) {
alert('Could not parse config file: ' + err.message);
}
};
reader.readAsText(file);
});
}
function escHtml(s) {
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
}
})();
</script>
<script nonce="{{ csp_nonce }}">
(function(){
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'}];
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);});}
try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
function init(){
var btn=document.getElementById('settings-btn');if(!btn)return;
var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
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>';
document.body.appendChild(m);
var g=document.getElementById('scheme-grid');
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);});
var cl=document.getElementById('settings-close');
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);
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');});
if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
}
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
}());
</script>
<script nonce="{{ csp_nonce }}">(function(){var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';
if(location.protocol==='file:'){if(lbl)lbl.textContent='Offline';if(dot){dot.style.background='#888';dot.style.boxShadow='none';}if(pingEl)pingEl.textContent='';if(fm)fm.textContent='oxide-sloc v{{ version }} \u2014 Saved Report';var td=document.querySelector('.server-status-tip');if(td)td.textContent='Saved HTML report \u2014 server not connected.';return;}
if(lbl)lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{{ version }} — Mode: '+(isServer?'Network Server':'Local');function setDot(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}function doPing(){var t0=performance.now();fetch('/healthz',{cache:'no-store'}).then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}).catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});}doPing();setInterval(doPing,5000);})();</script>
</body>
</html>
"##,
ext = "html"
)]
struct ScanSetupTemplate {
version: &'static str,
recent_scans_json: String,
csp_nonce: String,
}
#[derive(Template)]
#[template(
source = r##"
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OxideSLOC | {{ report_title }} | Report</title>
<link rel="icon" type="image/png" href="/images/logo/small-logo.png">
<style nonce="{{ csp_nonce }}">
:root {
--radius: 18px;
--bg: #f5efe8;
--surface: rgba(255,255,255,0.82);
--surface-2: #fbf7f2;
--surface-3: #efe6dc;
--line: #e6d0bf;
--line-strong: #dcb89f;
--text: #43342d;
--muted: #7b675b;
--muted-2: #a08777;
--nav: #b85d33;
--nav-2: #7a371b;
--accent: #6f9bff;
--accent-2: #4a78ee;
--oxide: #d37a4c;
--oxide-2: #b35428;
--shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
--shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
--success-bg: #e8f5ed;
--success-text: #1a8f47;
--info-bg: #eef3ff;
--info-text: #4467d8;
}
body.dark-theme {
--bg: #1b1511;
--surface: #261c17;
--surface-2: #2d221d;
--surface-3: #372922;
--line: #524238;
--line-strong: #6c5649;
--text: #f5ece6;
--muted: #c7b7aa;
--muted-2: #aa9485;
--nav: #b85d33;
--nav-2: #7a371b;
--accent: #6f9bff;
--accent-2: #4a78ee;
--oxide: #d37a4c;
--oxide-2: #b35428;
--shadow: 0 18px 42px rgba(0,0,0,0.28);
--shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
--success-bg: #163927;
--success-text: #8fe2a8;
--info-bg: #1c2847;
--info-text: #a9c1ff;
}
* { box-sizing: border-box; }
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); }
body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
.background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
.background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
.top-nav, .page { position: relative; z-index: 2; }
.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); }
.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; }
.brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
.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)); }
.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; }
.brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
.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; line-height: 1.2; margin-top: 2px; }
.nav-project-slot { display:flex; justify-content:center; min-width:0; }
.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; }
.nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
.nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
@media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
@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; } }
.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; }
.theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
.theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
.theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
.theme-toggle .icon-sun { display:none; }
body.dark-theme .theme-toggle .icon-sun { display:block; }
body.dark-theme .theme-toggle .icon-moon { display:none; }
.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;}
.settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
.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);}
.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;}
.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;}
.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;}
.scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
.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;}
.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);}
.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;}
.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;}
.tz-select:focus{border-color:var(--oxide);}
.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; }
.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;}
.page { width: 100%; max-width: 1720px; margin: 0 auto; padding: 32px 24px 36px; }
.hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
.hero, .panel { padding: 22px; }
.hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
.hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
.hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
.hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
.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; }
.compare-banner-body { display:flex; flex-direction:column; gap: 10px; }
.compare-banner-top { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
.compare-banner-actions { display:flex; align-items:center; justify-content:space-between; gap:8px; flex-wrap:wrap; border-top: 1px solid rgba(100,130,220,0.15); padding-top: 10px; }
.compare-banner-actions-left { display:flex; gap:8px; flex-wrap:wrap; }
.compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
.delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
.delta-chip.pos { background:var(--pos-bg); color:var(--pos); }
.delta-chip.neg { background:var(--neg-bg); color:var(--neg); }
.delta-cards-inline { display:flex; flex-wrap:wrap; gap:8px; flex:1 1 auto; align-items:center; justify-content:center; }
.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; }
.delta-card-inline:hover { transform:translateY(-3px); box-shadow:0 8px 20px rgba(77,44,20,0.18); z-index:10; }
.delta-card-val { font-size:16px; font-weight:800; }
.delta-card-val.pos { color:#1e7e34; }
.delta-card-val.neg { color:var(--neg); }
.delta-card-val.mod { color:#b35428; }
.delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
.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; }
.delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
.delta-card-inline:hover .delta-card-tip { opacity:1; }
.compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
.compare-ts { font-size:13px; color:var(--muted); }
.compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
.compare-arrow { color: var(--muted); }
.action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
.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; }
.action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
.action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
.run-mgmt-strip { display:flex; flex-wrap:wrap; gap:14px; align-items:stretch; margin-top:18px; }
.run-mgmt-card { flex:1; min-width:220px; padding:12px 16px; border-radius:14px; border:1px solid var(--line); background:var(--surface-2); display:flex; flex-direction:column; align-items:center; gap:6px; text-align:center; }
.run-mgmt-card h3 { margin:0 0 4px; font-size:14px; font-weight:800; }
.run-mgmt-card .action-buttons { justify-content:center; }
.run-mgmt-card .action-empty-note { font-size:11px; color:var(--muted); margin:0; text-align:center; }
body.dark-theme .run-mgmt-card { background:var(--surface-2); border-color:var(--line); }
.button, .copy-button {
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;
}
.button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
@keyframes spin { to { transform: rotate(360deg); } }
.path-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
.path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
.path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
.path-item strong { display: block; margin-bottom: 6px; }
.path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
.path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
.path-subitem { flex: 1; }
.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); }
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); }
.two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
.metrics-table th:first-child, .metrics-table td:first-child { width: 28%; }
th { color: var(--muted); font-weight: 700; }
tr:last-child td { border-bottom: none; }
#subm-tbl col:nth-child(1){width:15%;}
#subm-tbl col:nth-child(2){width:31%;}
#subm-tbl col:nth-child(3){width:9%;}
#subm-tbl col:nth-child(4){width:9%;}
#subm-tbl col:nth-child(5){width:9%;}
#subm-tbl col:nth-child(6){width:9%;}
#subm-tbl col:nth-child(7){width:9%;}
#subm-tbl col:nth-child(8){width:9%;}
.preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
iframe { width: 100%; min-height: 1000px; border: none; background: white; }
.empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
.pill-row { display:flex; gap:8px; flex-wrap:wrap; }
.hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
.hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
.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; }
.soft-chip.success { gap:5px; padding:0 10px 0 8px; min-height:22px; background:rgba(26,143,71,0.06); color:var(--muted); border:1px solid rgba(26,143,71,0.18); font-size:11px; font-weight:600; letter-spacing:0.03em; }
.soft-chip.success svg { flex:0 0 auto; opacity:0.75; }
body.dark-theme .soft-chip.success { background:rgba(143,226,168,0.07); border-color:rgba(143,226,168,0.18); }
.toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
.muted { color: var(--muted); }
/* Run-ID chip row (mirrors HTML report) */
.run-id-row { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-top:14px; }
@media(max-width:960px) { .run-id-row { grid-template-columns:1fr 1fr; } }
@media(max-width:560px) { .run-id-row { grid-template-columns:1fr; } }
.run-id-chip { display:flex; flex-direction:column; gap:5px; padding:12px 14px; border-radius:10px; background:var(--surface-2); border:1px solid var(--line); border-left:3px solid var(--accent); color:var(--text); position:relative; cursor:default; transition:transform 0.18s ease,box-shadow 0.18s ease; min-width:0; }
.run-id-chip[data-copy] { cursor:pointer; }
a.run-id-chip { text-decoration:none; cursor:pointer; }
.run-id-chip:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; }
.run-id-chip.muted-chip { border-left-color:var(--line-strong); }
.run-id-chip-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.1em; color:var(--accent); display:flex; align-items:center; gap:4px; }
.run-id-chip.muted-chip .run-id-chip-label { color:var(--muted-2); }
.run-id-chip-value { font-family:ui-monospace,monospace; font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.author-handle { font-size:11px; font-weight:600; color:var(--muted-2); margin-left:1.5em; font-family:ui-monospace,monospace; }
.run-id-chip.muted-chip .run-id-chip-value { color:var(--muted); font-style:italic; }
a.commit-link-value { color:inherit; text-decoration:none; }
a.commit-link-value:hover { color:var(--accent); text-decoration:underline; }
.chip-tooltip { position:absolute; top:calc(100% + 8px); left:50%; transform:translateX(-50%); background:var(--text); color:var(--bg); padding:6px 11px; border-radius:8px; font-size:11px; font-weight:500; white-space:nowrap; pointer-events:none; opacity:0; transition:opacity 0.18s ease; z-index:200; box-shadow:0 4px 16px rgba(0,0,0,0.25); line-height:1.4; }
.chip-tooltip::before { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
.run-id-chip:hover .chip-tooltip { opacity:1; }
.chip-label-icon { display:inline-block; vertical-align:middle; opacity:0.8; flex:0 0 auto; }
.run-id-short-badge { font-family:ui-monospace,monospace; font-size:13px; font-weight:700; color:var(--muted); background:var(--surface-2); border:1px solid var(--line); border-radius:6px; padding:2px 8px; letter-spacing:0.04em; white-space:nowrap; align-self:center; }
body.dark-theme .run-id-short-badge { color:var(--muted-2); }
@keyframes chip-flash { 0%{background:var(--accent);color:#fff;} 80%{background:var(--accent);color:#fff;} 100%{background:var(--surface-2);color:var(--text);} }
.chip-copied-flash { animation:chip-flash 0.9s ease forwards; }
/* Meta chips row */
.meta { display:flex; flex-wrap:wrap; align-items:center; gap:0; margin:14px 0 0; padding:10px 0; border-top:1px solid var(--line); border-bottom:1px solid var(--line); width:100%; }
.meta-chip { flex:1; display:inline-flex; align-items:center; justify-content:center; gap:5px; padding:0 10px; font-size:13px; font-weight:500; color:var(--muted); border-right:1px solid var(--line); line-height:1.8; }
.meta-chip:last-child { border-right:none; }
.meta-chip b { color:var(--text); font-weight:700; }
.site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
.site-footer a{color:var(--muted);}
.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; }
.open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
.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; }
.action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
/* Stat chips (matches HTML report) */
.summary-strip { display:grid; grid-template-columns:repeat(8,1fr); gap:10px; margin-top:18px; }
@media(max-width:1200px){.summary-strip{grid-template-columns:repeat(4,1fr);}}
@media(max-width:640px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
.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; }
.stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
.stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:6px; }
.stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
.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; }
.stat-chip-tip { position:absolute; top:calc(100% + 10px); left:50%; transform:translateX(-50%); background:var(--text); color:var(--bg); padding:10px 14px; border-radius:8px; font-size:12px; line-height:1.55; white-space:normal; max-width:420px; min-width:200px; text-align:left; pointer-events:none; opacity:0; transition:opacity .2s ease; z-index:200; box-shadow:0 4px 18px rgba(0,0,0,0.25); }
.stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
.stat-chip:hover .stat-chip-tip { opacity:1; }
.cocomo-box { background:var(--surface-2); border:1px solid var(--line); border-radius:14px; padding:20px 22px; }
.cocomo-box-head { display:flex; align-items:center; gap:10px; margin-bottom:16px; padding-bottom:14px; border-bottom:1px solid var(--line); flex-wrap:wrap; }
.cocomo-box-title { font-size:18px; font-weight:750; color:var(--text); letter-spacing:-0.01em; }
.cocomo-mode-pill-wrap { position:relative; display:inline-flex; align-items:center; cursor:help; }
.cocomo-mode-pill { display:inline-flex; align-items:center; padding:3px 10px; border-radius:999px; background:var(--surface-3); border:1px solid var(--line-strong); font-size:11px; font-weight:700; color:var(--muted); }
.cocomo-mode-tip { position:absolute; top:calc(100% + 8px); left:0; background:var(--text); color:var(--bg); padding:9px 13px; border-radius:8px; font-size:11px; font-weight:500; line-height:1.55; white-space:normal; max-width:300px; min-width:180px; pointer-events:none; opacity:0; transition:opacity .2s ease; z-index:300; box-shadow:0 4px 18px rgba(0,0,0,0.25); }
.cocomo-mode-tip::before { content:''; position:absolute; bottom:100%; left:14px; border:5px solid transparent; border-bottom-color:var(--text); }
.cocomo-mode-pill-wrap:hover .cocomo-mode-tip { opacity:1; }
.cocomo-box-note { font-size:13px; color:var(--muted); margin-top:10px; line-height:1.6; }
/* Submodule panel */
.submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
/* Metrics tables stack */
.metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
.metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
@media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
.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)); }
.metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
/* Metrics table */
.metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
.metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
.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; }
.metrics-table thead th:not(:first-child) { text-align: right; }
.metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
.metrics-table tbody tr:last-child td { border-bottom: none; }
.metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
.metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
.metrics-table tbody tr:hover td { background: var(--surface-2); }
.mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
.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; }
.metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
.mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
.mt-val-pos { color: var(--pos); font-weight: 700; }
.mt-val-neg { color: var(--neg); font-weight: 700; }
.mt-val-zero { color: var(--muted); }
.mt-val-mod { color: var(--oxide-2); }
.mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
@media (max-width: 1180px) {
.top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
.nav-project-slot, .nav-status { justify-content:flex-start; }
.hero-top { flex-direction: column; }
.run-mgmt-strip { flex-direction: column; }
}
.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;}
@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));}}
.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;}
/* ── Result-page chart controls ─────────────────────────────────────────── */
.r-chart-section{margin-bottom:24px;}
.section-pair{display:flex;flex-direction:column;gap:24px;width:100%;margin-top:24px;}
.section-pair > .panel{flex-shrink:0;}
.r-chart-controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
.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;}
.r-chart-select:focus{border-color:var(--accent);}
.r-chart-container{width:100%;overflow:hidden;position:relative;flex:1;}
.r-chart-container svg{display:block;width:100%;height:auto;}
.r-expand-btn{background:none;border:1px solid var(--line);border-radius:6px;cursor:pointer;color:var(--muted);padding:4px 10px;font-size:13px;line-height:1;transition:background .13s,color .13s;flex-shrink:0;white-space:nowrap;}
.r-expand-btn:hover{background:var(--surface);color:var(--text);}
.r-chart-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.55);z-index:9999;display:flex;align-items:center;justify-content:center;padding:24px;box-sizing:border-box;}
.r-chart-modal{background:var(--bg);border-radius:16px;padding:24px 28px;max-width:960px;width:100%;max-height:85vh;overflow-y:auto;position:relative;box-shadow:0 24px 80px rgba(0,0,0,0.3);}
.r-chart-modal-title{font-size:15px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;color:var(--text);margin:0 0 2px;display:block;}
.r-chart-modal-subtitle{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 12px;display:block;letter-spacing:.02em;}
.r-modal-header{display:flex;align-items:center;gap:12px;flex-wrap:nowrap;margin:0 0 16px;padding-right:44px;}
.r-modal-header .r-chart-modal-title{flex:1 1 auto;margin:0;min-width:0;}
.r-chart-modal-close{position:absolute;top:14px;right:18px;background:none;border:none;font-size:22px;cursor:pointer;color:var(--text);line-height:1;padding:0;}
.r-chart-modal-close:hover{opacity:.7;}
body.dark-theme .r-chart-modal{background:var(--surface);}
.r-chart-container .rchit,.r-expand-modal-chart .rchit,#result-lang-charts .rchit,#result-lang-overview-modal-wrap .rchit{cursor:pointer;transition:opacity .17s,filter .17s,transform .17s;transform-box:fill-box;transform-origin:center center;}
.r-chart-container .rchit:hover,.r-expand-modal-chart .rchit:hover,#result-lang-charts .rchit:hover,#result-lang-overview-modal-wrap .rchit:hover{filter:brightness(1.15) drop-shadow(0 2px 6px rgba(0,0,0,.18));transform:scale(1.05);}
.lang-bar-row{cursor:pointer;transition:transform .2s cubic-bezier(.34,1.56,.64,1);}
.lang-bar-row:hover{transform:translateY(-2px);}
.lang-bar-row .rchit:hover{filter:none;transform:none;}
.lang-bar-row:hover .rchit{filter:brightness(1.12);transform:scaleY(1.22);}
.r-chart-tab-bar{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
.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;}
.r-chart-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
.r-chart-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start;}
@media(max-width:720px){.r-chart-grid-2{grid-template-columns:1fr;}}
@media print{.r-chart-controls,.r-chart-tab-bar{display:none!important;}}
#r-tt{display:none;position:fixed;background:rgba(15,10,6,.95);color:#fff;border-radius:10px;padding:8px 13px;font-size:12px;line-height:1.5;pointer-events:none;z-index:10001;box-shadow:0 4px 20px rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.1);max-width:240px;white-space:nowrap;}
.r-lang-overview{display:flex;gap:40px;align-items:flex-start;justify-content:center;flex-wrap:wrap;padding:8px 0 16px;}
.r-lang-overview-cell{display:flex;flex-direction:column;align-items:center;gap:8px;flex:1 1 280px;max-width:480px;}
.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;}
.r-viz-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:stretch;}
@media(max-width:820px){.r-viz-grid{grid-template-columns:1fr;}}
.r-viz-card{border:1px solid var(--line);border-radius:12px;padding:14px 16px;background:var(--surface);box-shadow:var(--shadow);display:flex;flex-direction:column;}
.r-viz-card-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;}
.report-id-banner{background:var(--nav);color:#fff;font-size:11px;font-weight:700;letter-spacing:0.05em;display:flex;align-items:center;justify-content:center;height:27px;padding:0 16px;position:fixed;top:0;left:0;right:0;z-index:32;}
.report-id-footer-banner{background:var(--nav);color:#fff;font-size:11px;font-weight:700;letter-spacing:0.05em;display:flex;align-items:center;justify-content:center;height:27px;padding:0 16px;position:fixed;bottom:0;left:0;right:0;z-index:32;}
body.has-report-banner .top-nav{top:27px;}
body.has-report-banner{padding-bottom:27px;}
</style>
</head>
<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
<div class="background-watermarks" aria-hidden="true">
<img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" />
</div>
<div class="code-particles" id="code-particles" aria-hidden="true"></div>
{% if let Some(banner) = report_header_footer %}
<div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
{% endif %}
<div class="top-nav">
<div class="top-nav-inner">
<a class="brand" href="/">
<img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
<div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
</a>
<div class="nav-project-slot">
<div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ report_title }}</span></div>
</div>
<div class="nav-status">
<a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
<div class="nav-dropdown">
<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>
<div class="nav-dropdown-menu">
<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>
</div>
</div>
<a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
<a class="nav-pill" href="/test-metrics">Test Metrics</a>
<div class="nav-dropdown">
<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>
<div class="nav-dropdown-menu">
<a href="/integrations"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
</div>
</div>
<div class="server-status-wrap" id="server-status-wrap">
<div class="nav-pill server-online-pill" id="server-status-pill">
<span class="status-dot" id="status-dot"></span>
<span id="server-status-label">Server</span>
<span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
</div>
<div class="server-status-tip">
OxideSLOC is running — accessible on your network.
<span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
</div>
</div>
<button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
<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>
</button>
<button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
<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>
<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>
</button>
</div>
</div>
</div>
<div class="page">
<section class="hero">
<div class="hero-top">
<div>
<div style="display:flex;align-items:center;gap:18px;flex-wrap:wrap;">
<h1 class="hero-title" style="margin:0;">{{ report_title }}</h1>
<span class="run-id-short-badge" title="Short run ID — matches the ID shown in View Reports">{{ run_id_short }}</span>
<div class="soft-chip success" style="margin-left:auto;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg>Run finished successfully</div>
</div>
</div>
<div class="hero-quick-actions">
{% if server_mode %}
<button type="button" class="copy-button secondary" disabled title="Output folder is on the server — path is not meaningful for remote users" style="opacity:0.45;cursor:not-allowed;">Copy output folder</button>
{% else %}
<button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
{% endif %}
<button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
{% if !server_mode %}
<button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
{% endif %}
<button class="copy-button secondary" id="download-bundle-btn" type="button">Download all artifacts</button>
<button class="copy-button" id="delete-run-btn" type="button" style="background:#b23030;border-color:#b23030;color:#fff;box-shadow:0 12px 24px rgba(178,48,48,0.11);">Delete this run</button>
</div>
</div>
<!-- Run metadata chips: Run ID · Git Commit · Branch · Last Commit By -->
<div class="run-id-row">
<span class="run-id-chip" data-copy="{{ run_id }}">
<span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/><line x1="10" y1="3" x2="8" y2="21"/><line x1="16" y1="3" x2="14" y2="21"/></svg>Run ID</span>
<span class="run-id-chip-value">{{ run_id }}</span>
<span class="chip-tooltip">Unique identifier for this analysis run — click to copy</span>
</span>
{% match git_commit_long %}
{% when Some with (long_sha) %}
{% match git_commit_url %}
{% when Some with (commit_url) %}
<a class="run-id-chip" href="{{ commit_url }}" target="_blank" rel="noopener">
<span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><line x1="1" y1="12" x2="7" y2="12"/><line x1="17" y1="12" x2="23" y2="12"/></svg>Git Commit<svg class="chip-label-icon" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="margin-left:4px;opacity:0.7;"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></span>
<span class="run-id-chip-value">{{ long_sha }}</span>
<span class="chip-tooltip">Open commit on version control — click to navigate</span>
</a>
{% when None %}
<span class="run-id-chip" data-copy="{{ long_sha }}">
<span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><line x1="1" y1="12" x2="7" y2="12"/><line x1="17" y1="12" x2="23" y2="12"/></svg>Git Commit</span>
<span class="run-id-chip-value">{{ long_sha }}</span>
<span class="chip-tooltip">Full commit SHA for the scanned state — click to copy</span>
</span>
{% endmatch %}
{% when None %}
<span class="run-id-chip muted-chip">
<span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><line x1="1" y1="12" x2="7" y2="12"/><line x1="17" y1="12" x2="23" y2="12"/></svg>Git Commit</span>
<span class="run-id-chip-value">Not detected</span>
<span class="chip-tooltip">No Git commit SHA was found for this scan</span>
</span>
{% endmatch %}
{% match git_branch %}
{% when Some with (branch) %}
{% match git_branch_url %}
{% when Some with (branch_url) %}
<a class="run-id-chip" href="{{ branch_url }}" target="_blank" rel="noopener">
<span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>Branch<svg class="chip-label-icon" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="margin-left:4px;opacity:0.7;"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></span>
<span class="run-id-chip-value">{{ branch }}</span>
<span class="chip-tooltip">Open branch on version control — click to navigate</span>
</a>
{% when None %}
<span class="run-id-chip">
<span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>Branch</span>
<span class="run-id-chip-value">{{ branch }}</span>
<span class="chip-tooltip">Git branch active at scan time</span>
</span>
{% endmatch %}
{% when None %}
<span class="run-id-chip muted-chip">
<span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>Branch</span>
<span class="run-id-chip-value">Not detected</span>
<span class="chip-tooltip">No Git branch was found for this scan</span>
</span>
{% endmatch %}
{% match git_author %}
{% when Some with (author) %}
<span class="run-id-chip" data-author="{{ author }}">
<span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>Last Commit By</span>
<span class="run-id-chip-value">{{ author }}<span class="author-handle"></span></span>
<span class="chip-tooltip">Author of the most recent commit at scan time</span>
</span>
{% when None %}
<span class="run-id-chip muted-chip">
<span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>Last Commit By</span>
<span class="run-id-chip-value">Not detected</span>
<span class="chip-tooltip">No commit author was found for this scan</span>
</span>
{% endmatch %}
</div>
<!-- Scan metadata row -->
<div class="meta">
<span class="meta-chip">Scan by <b>{{ scan_performed_by }}</b></span>
<span class="meta-chip">Scanned <b>{{ scan_time_display }}</b></span>
<span class="meta-chip">OS <b>{{ os_display }}</b></span>
<span class="meta-chip">Files analyzed <b>{{ files_analyzed }}</b></span>
<span class="meta-chip">Files skipped <b>{{ files_skipped }}</b></span>
</div>
<!-- All summary stat chips in one unified strip (8 columns) -->
<div class="summary-strip">
<div class="stat-chip" data-raw="{{ physical_lines }}">
<div class="stat-chip-label">Physical lines</div>
<div class="stat-chip-val">{{ physical_lines }}</div>
<div class="stat-chip-exact"></div>
<div class="stat-chip-tip">Total lines across all analyzed files, including code, comments, and blank lines.</div>
</div>
<div class="stat-chip" data-raw="{{ code_lines }}">
<div class="stat-chip-label">Code</div>
<div class="stat-chip-val">{{ code_lines }}</div>
<div class="stat-chip-exact"></div>
<div class="stat-chip-tip">Lines containing executable source code, excluding comments and blanks.</div>
</div>
<div class="stat-chip" data-raw="{{ comment_lines }}">
<div class="stat-chip-label">Comments</div>
<div class="stat-chip-val">{{ comment_lines }}</div>
<div class="stat-chip-exact"></div>
<div class="stat-chip-tip">Lines consisting entirely of comments or inline documentation.</div>
</div>
<div class="stat-chip" data-raw="{{ blank_lines }}">
<div class="stat-chip-label">Blank</div>
<div class="stat-chip-val">{{ blank_lines }}</div>
<div class="stat-chip-exact"></div>
<div class="stat-chip-tip">Empty or whitespace-only lines used for readability and spacing.</div>
</div>
<div class="stat-chip" data-raw="{{ mixed_lines }}">
<div class="stat-chip-label">Mixed separate</div>
<div class="stat-chip-val">{{ mixed_lines }}</div>
<div class="stat-chip-exact"></div>
<div class="stat-chip-tip">Lines that contain both code and a trailing comment, counted separately per the mixed-line policy.</div>
</div>
<div class="stat-chip" data-raw="{{ functions }}">
<div class="stat-chip-label">Functions</div>
<div class="stat-chip-val">{{ functions }}</div>
<div class="stat-chip-exact"></div>
<div class="stat-chip-tip">Best-effort count of function/method definitions detected across all source files.</div>
</div>
<div class="stat-chip" data-raw="{{ classes }}">
<div class="stat-chip-label">Classes / Types</div>
<div class="stat-chip-val">{{ classes }}</div>
<div class="stat-chip-exact"></div>
<div class="stat-chip-tip">Best-effort count of class, struct, interface, and type definitions.</div>
</div>
<div class="stat-chip" data-raw="{{ variables }}">
<div class="stat-chip-label">Variables</div>
<div class="stat-chip-val">{{ variables }}</div>
<div class="stat-chip-exact"></div>
<div class="stat-chip-tip">Best-effort count of variable and constant declarations.</div>
</div>
<div class="stat-chip" data-raw="{{ imports }}">
<div class="stat-chip-label">Imports</div>
<div class="stat-chip-val">{{ imports }}</div>
<div class="stat-chip-exact"></div>
<div class="stat-chip-tip">Best-effort count of import, include, and module-use statements.</div>
</div>
<div class="stat-chip" data-raw="{{ test_count }}">
<div class="stat-chip-label">Tests</div>
<div class="stat-chip-val">{{ test_count }}</div>
<div class="stat-chip-exact"></div>
<div class="stat-chip-tip">Best-effort count of test cases detected by framework pattern (GTest, PyTest, JUnit, etc.).</div>
</div>
<div class="stat-chip" data-density data-code="{{ code_lines }}" data-physical="{{ physical_lines }}">
<div class="stat-chip-label">Code density</div>
<div class="stat-chip-val stat-chip-density-val">—</div>
<div class="stat-chip-exact"></div>
<div class="stat-chip-tip">Percentage of physical lines that contain executable source code — higher means a leaner, code-dense codebase.</div>
</div>
<div class="stat-chip" data-raw="{{ files_analyzed }}">
<div class="stat-chip-label">Files analyzed</div>
<div class="stat-chip-val">{{ files_analyzed }}</div>
<div class="stat-chip-exact"></div>
<div class="stat-chip-tip">Total number of source files included in this analysis.</div>
</div>
{% if cyclomatic_complexity > 0 %}
<div class="stat-chip" data-raw="{{ cyclomatic_complexity }}" {% if complexity_alert > 0 && cyclomatic_complexity > complexity_alert as u64 %}style="border-color:var(--oxide-2);"{% endif %}>
<div class="stat-chip-label">Complexity score</div>
<div class="stat-chip-val">{{ cyclomatic_complexity }}</div>
<div class="stat-chip-exact"></div>
<div class="stat-chip-tip">Sum of branch decision keywords (if, for, while, ||, &&, …) across all code lines — a lexical approximation of McCabe cyclomatic complexity.{% if complexity_alert > 0 %} Alert threshold: {{ complexity_alert }}.{% endif %}</div>
</div>
{% endif %}
{% if let Some(ls) = lsloc %}
<div class="stat-chip" data-raw="{{ ls }}">
<div class="stat-chip-label">Logical SLOC</div>
<div class="stat-chip-val">{{ ls }}</div>
<div class="stat-chip-exact"></div>
<div class="stat-chip-tip">Count of executable statements (semicolons for C/Java/Go/Rust; non-continuation lines for Python/Ruby/Shell). Normalises across formatting styles.</div>
</div>
{% endif %}
{% if uloc > 0 %}
<div class="stat-chip" data-raw="{{ uloc }}">
<div class="stat-chip-label">Unique SLOC (ULOC)</div>
<div class="stat-chip-val">{{ uloc }}</div>
<div class="stat-chip-exact"></div>
<div class="stat-chip-tip">Unique Lines of Code: distinct non-blank code lines across all files. Counts each line once regardless of how many files it appears in.</div>
</div>
{% endif %}
{% if uloc > 0 && dryness_pct_str != "" %}
<div class="stat-chip">
<div class="stat-chip-label">DRYness</div>
<div class="stat-chip-val">{{ dryness_pct_str }}%</div>
<div class="stat-chip-exact"></div>
<div class="stat-chip-tip">ULOC ÷ Code Lines — the fraction of code lines that are unique. Higher = less copy-paste across the codebase. 100% means every code line is distinct.</div>
</div>
{% endif %}
{% if duplicate_group_count > 0 %}
<div class="stat-chip" data-raw="{{ duplicate_group_count }}" style="border-color:rgba(179,93,51,0.4);">
<div class="stat-chip-label">Duplicate groups</div>
<div class="stat-chip-val">{{ duplicate_group_count }}</div>
<div class="stat-chip-exact"></div>
<div class="stat-chip-tip">Groups of files with identical content detected. These may inflate SLOC counts. Enable "Exclude duplicates" in scan settings to remove them from totals.</div>
</div>
{% endif %}
</div>
{% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
<div class="compare-banner">
<div class="compare-banner-body">
<div class="compare-banner-top">
<div class="compare-banner-meta">
<span class="compare-label">Previous scan</span>
<span class="compare-ts">{{ prev_ts }}</span>
{% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
{% if let Some(prev_code) = prev_run_code_lines %}
<div class="compare-banner-stats" style="margin-top:4px;">
<span>Code before: <strong>{{ prev_code }}</strong></span>
<span class="compare-arrow">→</span>
<span>Code now: <strong>{{ code_lines }}</strong></span>
{% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+{{ added }} added</span>{% endif %}
{% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">−{{ removed }} removed</span>{% endif %}
</div>
{% endif %}
</div>
{% if delta_lines_added.is_some() %}
<div class="delta-cards-inline">
<div class="delta-card-inline">
<div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v }}{% else %}—{% endif %}</div>
<div class="delta-card-lbl">lines added</div>
<div class="delta-card-tip">Code lines added since the previous scan</div>
</div>
<div class="delta-card-inline">
<div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}−{{ v }}{% else %}—{% endif %}</div>
<div class="delta-card-lbl">lines removed</div>
<div class="delta-card-tip">Code lines removed since the previous scan</div>
</div>
<div class="delta-card-inline">
<div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v }}{% else %}—{% endif %}</div>
<div class="delta-card-lbl">unmodified lines</div>
<div class="delta-card-tip">Code lines unchanged since the previous scan</div>
</div>
<div class="delta-card-inline">
<div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v }}{% else %}—{% endif %}</div>
<div class="delta-card-lbl">files modified</div>
<div class="delta-card-tip">Files with at least one line changed</div>
</div>
<div class="delta-card-inline">
<div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v }}{% else %}—{% endif %}</div>
<div class="delta-card-lbl">files added</div>
<div class="delta-card-tip">New files added since the previous scan</div>
</div>
<div class="delta-card-inline">
<div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v }}{% else %}—{% endif %}</div>
<div class="delta-card-lbl">files removed</div>
<div class="delta-card-tip">Files deleted since the previous scan</div>
</div>
<div class="delta-card-inline">
<div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v }}{% else %}—{% endif %}</div>
<div class="delta-card-lbl">files unchanged</div>
<div class="delta-card-tip">Files with no changes since the previous scan</div>
</div>
</div>
{% else %}
<p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
</p>
{% endif %}
</div>
<div class="compare-banner-actions">
<div class="compare-banner-actions-left">
<a class="button secondary" href="/runs/result/{{ prev_id }}" style="white-space:nowrap;">View previous report</a>
<a class="button secondary" href="/compare-scans" style="white-space:nowrap;">Compare scans</a>
</div>
<a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;">Full diff →</a>
</div>
</div>
</div>
{% endif %}{% endif %}
<div class="action-grid">
<div class="action-card">
<h3>HTML report</h3>
<div class="action-buttons">
{% match html_url %}
{% when Some with (url) %}
<a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
{% when None %}{% endmatch %}
{% match html_download_url %}
{% when Some with (url) %}
<a class="button secondary" href="{{ url }}">Download HTML</a>
{% when None %}{% endmatch %}
{% match html_path %}
{% when Some with (_path) %}{% when None %}{% endmatch %}
<p class="action-empty-note" style="margin-top:6px;">Interactive report with charts, language breakdown, and per-file detail. Opens in your browser.</p>
</div>
</div>
<div class="action-card">
<h3>PDF report</h3>
<div class="action-buttons">
{% match pdf_url %}
{% when Some with (url) %}
{% if pdf_generating %}
<button class="button" id="pdf-open-btn" disabled style="opacity:0.55;cursor:not-allowed;gap:8px;">
<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>
Generating PDF…
</button>
{% else %}
<a class="button" href="{{ url }}" target="_blank" rel="noopener" id="pdf-open-btn">Open PDF</a>
{% endif %}
{% when None %}
{% match html_url %}
{% when Some with (_hurl) %}
<a class="button" href="/runs/pdf/{{ run_id }}" target="_blank" rel="noopener" id="pdf-open-btn">Generate PDF</a>
<p class="action-empty-note" style="margin-top:6px;font-size:11px;">Generates the PDF report from the scan results. Usually completes within a few seconds.</p>
{% when None %}
<p class="action-empty-note" style="color:var(--muted);font-size:12px;background:rgba(0,0,0,0.04);border:1px solid var(--line);border-radius:8px;padding:10px 12px;">
PDF could not be generated for this run — Chromium or Edge may not be installed. The HTML report is always available above.
</p>
{% endmatch %}
{% endmatch %}
{% match pdf_download_url %}
{% when Some with (url) %}
<a class="button secondary" href="{{ url }}" id="pdf-download-btn"{% if pdf_generating %} style="opacity:0.55;pointer-events:none;"{% endif %}>Download PDF</a>
{% when None %}{% endmatch %}
{% match pdf_url %}
{% when Some with (_) %}
<p class="action-empty-note" style="margin-top:6px;">Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
{% when None %}{% endmatch %}
</div>
</div>
<div class="action-card">
<h3>JSON result</h3>
<div class="action-buttons">
{% match json_url %}
{% when Some with (url) %}
<a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
{% when None %}{% endmatch %}
{% match json_download_url %}
{% when Some with (url) %}
<a class="button secondary" href="{{ url }}">Download JSON</a>
{% when None %}{% endmatch %}
{% match json_path %}
{% when Some with (_path) %}
<p class="action-empty-note" style="margin-top:6px;">Machine-readable scan result for CI pipelines, scripting, or re-rendering reports.</p>
{% when None %}
<p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
{% endmatch %}
</div>
</div>
<div class="action-card">
<h3>Scan config</h3>
<div class="action-buttons">
<a class="button secondary" href="{{ scan_config_url }}">Download config</a>
<a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
<p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
</div>
</div>
{% if confluence_configured %}
<div class="action-card" id="confluenceCard">
<h3>Confluence</h3>
<div class="action-buttons">
<button class="button" id="postConfluenceBtn" type="button">Post to Confluence</button>
<button class="button secondary" id="copyWikiBtn" type="button">Copy Wiki Markup</button>
</div>
<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>
</div>
{% endif %}
</div>
{% if confluence_configured %}
<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;">
<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);">
<div style="font-size:16px;font-weight:800;margin-bottom:18px;">Post to Confluence</div>
<label style="font-size:12px;font-weight:700;color:var(--muted);">Page Title</label>
<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;">
<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>
<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;">
<div id="confStatus" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
<div style="display:flex;gap:10px;justify-content:flex-end;">
<button class="button secondary" id="confCancelBtn" type="button">Cancel</button>
<button class="button" id="confSubmitBtn" type="button">Post</button>
</div>
</div>
</div>
{% endif %}
<div id="delete-run-modal" style="display:none;position:fixed;inset:0;z-index:500;background:rgba(0,0,0,0.90);align-items:center;justify-content:center;">
<div style="background:var(--surface);border:1px solid var(--line);border-radius:22px;padding:56px 72px;max-width:820px;width:95%;box-shadow:0 24px 72px rgba(0,0,0,0.55);">
<div style="font-size:28px;font-weight:800;margin-bottom:16px;color:#b23030;">Delete run — irreversible</div>
<p style="font-size:17px;color:var(--text);margin:0 0 28px;">This will permanently delete all artifacts for this run from disk (HTML, PDF, JSON, CSV, scan config). <strong>This cannot be undone</strong> and the run will no longer be accessible by anyone.</p>
<div id="delete-run-status" style="display:none;padding:14px 20px;border-radius:10px;font-size:15px;font-weight:600;margin-bottom:22px;"></div>
<div style="display:flex;gap:18px;justify-content:flex-end;">
<button class="button secondary" id="delete-run-cancel" type="button" style="font-size:15px;padding:12px 28px;">Cancel</button>
<button class="button" id="delete-run-confirm" type="button" style="background:#b23030;border-color:#b23030;font-size:15px;padding:12px 28px;">Yes, delete permanently</button>
</div>
</div>
</div>
{% if !submodule_rows.is_empty() %}
<div class="submodule-panel">
<div class="toolbar-row">
<div>
<h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
<p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
</div>
<div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
</div>
<div style="overflow-x:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
<table id="subm-tbl" style="width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed;min-width:1050px;">
<colgroup><col style="width:24%"><col style="width:22%"><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>
<thead>
<tr>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
</tr>
</thead>
<tbody>
{% for row in submodule_rows %}
<tr>
<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>
<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>
<td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.files_analyzed }}</td>
<td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.total_physical_lines }}</td>
<td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.code_lines }}</td>
<td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.comment_lines }}</td>
<td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.blank_lines }}</td>
<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>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<div class="metrics-tables-stack">
<div class="metrics-table-wrap">
<div class="metrics-table-title">Files</div>
<table class="metrics-table">
<thead>
<tr>
<th>Metric</th>
<th>This Run</th>
<th>Previous</th>
<th>Change</th>
</tr>
</thead>
<tbody>
<tr>
<td>Files analyzed</td>
<td class="mt-val-large">{{ files_analyzed }}</td>
<td>{{ prev_fa_str }}</td>
<td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str }}</span></td>
</tr>
<tr>
<td>Files skipped</td>
<td>{{ files_skipped }}</td>
<td>{{ prev_fs_str }}</td>
<td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str }}</span></td>
</tr>
<tr>
<td>Files modified</td>
<td class="mt-val-na">—</td>
<td class="mt-val-na">—</td>
<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>
</tr>
<tr>
<td>Files unchanged</td>
<td class="mt-val-na">—</td>
<td class="mt-val-na">—</td>
<td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
</tr>
</tbody>
</table>
</div>
<div class="metrics-table-wrap">
<div class="metrics-table-title">Line Counts</div>
<table class="metrics-table">
<thead>
<tr>
<th>Metric</th>
<th>This Run</th>
<th>Previous</th>
<th>Change</th>
</tr>
</thead>
<tbody>
<tr>
<td>Physical lines</td>
<td class="mt-val-large">{{ physical_lines }}</td>
<td>{{ prev_pl_str }}</td>
<td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str }}</span></td>
</tr>
<tr>
<td>Code lines</td>
<td class="mt-val-large">{{ code_lines }}</td>
<td>{{ prev_cl_str }}</td>
<td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str }}</span></td>
</tr>
<tr>
<td>Comment lines</td>
<td>{{ comment_lines }}</td>
<td>{{ prev_cml_str }}</td>
<td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str }}</span></td>
</tr>
<tr>
<td>Blank lines</td>
<td>{{ blank_lines }}</td>
<td>{{ prev_bl_str }}</td>
<td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str }}</span></td>
</tr>
<tr>
<td>Mixed (separate)</td>
<td>{{ mixed_lines }}</td>
<td class="mt-val-na">—</td>
<td class="mt-val-na">—</td>
</tr>
</tbody>
</table>
</div>
<div class="metrics-tables-lower">
<div class="metrics-table-wrap">
<div class="metrics-table-title">Code Structure</div>
<table class="metrics-table">
<thead>
<tr>
<th>Metric</th>
<th>This Run</th>
</tr>
</thead>
<tbody>
<tr>
<td>Functions</td>
<td>{{ functions }}</td>
</tr>
<tr>
<td>Classes / Types</td>
<td>{{ classes }}</td>
</tr>
<tr>
<td>Variables</td>
<td>{{ variables }}</td>
</tr>
<tr>
<td>Imports</td>
<td>{{ imports }}</td>
</tr>
</tbody>
</table>
</div>
<div class="metrics-table-wrap">
<div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
<table class="metrics-table">
<thead>
<tr>
<th>Metric</th>
<th>Change</th>
</tr>
</thead>
<tbody>
<tr>
<td>Lines added</td>
<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>
</tr>
<tr>
<td>Lines removed</td>
<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>
</tr>
<tr>
<td>Lines modified (net)</td>
<td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str }}</span></td>
</tr>
<tr>
<td>Lines unmodified</td>
<td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="path-list">
<div class="path-item">
<div class="path-item-label">Project path</div>
<code>{{ project_path }}</code>
</div>
<div class="path-item">
<div class="path-item-label">Git branch</div>
{% if let Some(branch) = git_branch %}
<code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
{% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
{% else %}
<code style="color:var(--muted)">—</code>
{% endif %}
</div>
<div class="path-item">
<div class="path-item-label">Output folder</div>
<code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
</div>
<div class="path-item">
<div class="path-item-label">Run ID</div>
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
<code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
<span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
</div>
</div>
</div>
</section>
{% if has_cocomo %}
<div class="cocomo-box" style="margin-top:24px;">
<div class="cocomo-box-head">
<span class="cocomo-box-title">Constructive Cost Model — COCOMO I</span>
<span class="cocomo-mode-pill-wrap" style="margin-left:10px;">
<span class="cocomo-mode-pill">{{ cocomo_mode_label }} mode</span>
<span class="cocomo-mode-tip">{{ cocomo_mode_tooltip }}</span>
</span>
</div>
<div class="summary-strip" style="margin-top:0;grid-template-columns:repeat(4,1fr);">
<div class="stat-chip">
<div class="stat-chip-label">Person-months</div>
<div class="stat-chip-val">{{ cocomo_effort_str }}</div>
<div class="stat-chip-tip">Total estimated developer effort to build this codebase from scratch. One person-month = one developer working full-time for one calendar month. Computed as 2.4 × KSLOC^1.05 ({{ cocomo_mode_label }} mode).</div>
</div>
<div class="stat-chip">
<div class="stat-chip-label">Schedule (months)</div>
<div class="stat-chip-val">{{ cocomo_duration_str }}</div>
<div class="stat-chip-tip">Estimated calendar duration assuming an optimally sized team. Computed as 2.5 × effort^0.38. Adding more people beyond this optimum rarely shortens the timeline.</div>
</div>
<div class="stat-chip">
<div class="stat-chip-label">Avg. Team Size</div>
<div class="stat-chip-val">{{ cocomo_staff_str }}</div>
<div class="stat-chip-tip">Average number of engineers working in parallel, derived as effort ÷ schedule. Actual headcount may peak higher during intensive phases of the project.</div>
</div>
<div class="stat-chip">
<div class="stat-chip-label">Input KSLOC</div>
<div class="stat-chip-val">{{ cocomo_ksloc_str }}K</div>
<div class="stat-chip-tip">KSLOC = Kilo Source Lines of Code (1 KSLOC = 1,000 lines). This is the primary input to the COCOMO model. Only executable code lines are counted — blank lines and comments are excluded from this total.</div>
</div>
</div>
<div class="cocomo-box-note" style="white-space:nowrap;">COCOMO I (Constructive Cost Model) is a 1981 algorithmic model by Barry Boehm that converts SLOC into effort, schedule, and team-size estimates.<br>These are ballpark figures — actual outcomes vary widely by team experience, toolchain maturity, and domain complexity.</div>
</div>
{% endif %}
<div class="section-pair">
<section class="panel">
<div class="toolbar-row">
<div>
<h2>Language breakdown</h2>
<p class="muted">A quick summary of what this run actually counted across supported languages.</p>
</div>
<button class="r-expand-btn" id="result-lang-overview-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
</div>
<div id="result-lang-charts" style="margin:0 0 8px;"></div>
</section>
<section class="panel r-chart-section">
<div class="toolbar-row" style="margin-bottom:16px;">
<div>
<h2>Visualizations</h2>
<p class="muted">Interactive charts for this scan — use the controls to switch views.</p>
</div>
</div>
<div class="r-viz-grid">
<div class="r-viz-card">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
<p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Language Composition</p>
<button class="r-expand-btn" id="r-composition-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
</div>
<div class="r-chart-tab-bar">
<button class="r-chart-tab active" data-rcomp="abs">Absolute</button>
<button class="r-chart-tab" data-rcomp="pct">100% Normalized</button>
</div>
<div class="r-chart-container" id="r-composition-chart"></div>
</div>
<div class="r-viz-card">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
<p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Files vs Code Lines</p>
<button class="r-expand-btn" id="r-scatter-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
</div>
<div class="r-chart-container" id="r-scatter-chart"></div>
</div>
{% if has_semantic_data %}
<div class="r-viz-card">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
<p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Semantic Metrics</p>
<select class="r-chart-select" id="r-semantic-metric">
<option value="functions">Functions</option>
<option value="classes">Classes</option>
<option value="variables">Variables</option>
<option value="imports">Imports</option>
<option value="tests">Tests</option>
</select>
<button class="r-expand-btn" id="r-semantic-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
</div>
<div class="r-chart-container" id="r-semantic-chart"></div>
</div>
{% endif %}
<div class="r-viz-card">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
<p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Comment Density</p>
<button class="r-expand-btn" id="r-density-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
</div>
<div class="r-chart-container" id="r-density-chart"></div>
</div>
<div class="r-viz-card">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
<p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Avg Lines per File</p>
<button class="r-expand-btn" id="r-avglines-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
</div>
<div class="r-chart-container" id="r-avglines-chart"></div>
</div>
<div class="r-viz-card">
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
<p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Repository Overview</p>
<select class="r-chart-select" id="r-sub-metric">
<option value="code">Code Lines</option>
<option value="comment">Comments</option>
<option value="blank">Blank Lines</option>
<option value="physical">Physical Lines</option>
<option value="files">Files</option>
</select>
<select class="r-chart-select" id="r-sub-sort">
<option value="desc">Value ↓</option>
<option value="asc">Value ↑</option>
<option value="name">Name A→Z</option>
</select>
<button class="r-expand-btn" id="r-submodule-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
</div>
<div class="r-chart-container" id="r-submodule-chart"></div>
</div>
</div>
</section>
</div>
</div>
<div id="r-tt" aria-hidden="true"></div>
<script nonce="{{ csp_nonce }}">
(function () {
var body = document.body;
var themeToggle = document.getElementById('theme-toggle');
var storageKey = 'oxide-sloc-theme';
function applyTheme(theme) {
body.classList.toggle('dark-theme', theme === 'dark');
}
function loadSavedTheme() {
try {
var saved = localStorage.getItem(storageKey);
if (saved === 'dark' || saved === 'light') {
applyTheme(saved);
}
} catch (e) {}
}
if (themeToggle) {
themeToggle.addEventListener('click', function () {
var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
applyTheme(nextTheme);
try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
});
}
Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
button.addEventListener('click', function () {
var value = button.getAttribute('data-copy-value') || '';
if (!value) return;
var originalText = button.textContent;
function flashSuccess() {
button.textContent = 'Copied!';
setTimeout(function () { button.textContent = originalText; }, 1800);
}
function flashFail() {
button.textContent = 'Copy failed';
setTimeout(function () { button.textContent = originalText; }, 2000);
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(value).then(flashSuccess, function () {
fallbackCopy(value, flashSuccess, flashFail);
});
} else {
fallbackCopy(value, flashSuccess, flashFail);
}
});
});
function fallbackCopy(text, onSuccess, onFail) {
try {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.top = '-9999px';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.focus();
ta.select();
var ok = document.execCommand('copy');
document.body.removeChild(ta);
if (ok) { onSuccess(); } else { onFail(); }
} catch (e) { onFail(); }
}
Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
btn.addEventListener('click', function () {
var folder = btn.getAttribute('data-folder') || '';
if (!folder) return;
var orig = btn.textContent;
fetch('/open-path?path=' + encodeURIComponent(folder))
.then(function (r) { return r.json(); })
.then(function (d) {
if (d && d.server_mode_disabled) {
window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
} else if (d && d.ok) {
btn.textContent = 'Opened!';
setTimeout(function () { btn.textContent = orig; }, 1800);
}
})
.catch(function () {
btn.textContent = 'Failed';
setTimeout(function () { btn.textContent = orig; }, 2000);
});
});
});
loadSavedTheme();
// ── Compact number formatting for stat chips ──────────────────────────
(function(){
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(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}
Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-raw]')).forEach(function(chip){
var raw=parseInt(chip.getAttribute('data-raw'),10);
if(isNaN(raw))return;
var valEl=chip.querySelector('.stat-chip-val');
if(valEl)valEl.textContent=fmt(raw);
var exactEl=chip.querySelector('.stat-chip-exact');
if(exactEl)exactEl.textContent=raw>=10000?raw.toLocaleString():'';
});
// Code density chip
Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-density]')).forEach(function(chip){
var code=parseInt(chip.getAttribute('data-code'),10);
var phys=parseInt(chip.getAttribute('data-physical'),10);
if(isNaN(code)||isNaN(phys)||phys===0)return;
var pct=(code/phys*100).toFixed(1)+'%';
var valEl=chip.querySelector('.stat-chip-val');
if(valEl)valEl.textContent=pct;
});
// Populate author handle from data-author attribute
Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-author]')).forEach(function(chip){
var author=chip.getAttribute('data-author');
var el=chip.querySelector('.author-handle');
if(el)el.textContent='/'+author.replace(/\s+/g,'');
});
// Click-to-copy on run-id-chip elements
Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-copy]')).forEach(function(chip){
chip.addEventListener('click',function(){
var val=chip.getAttribute('data-copy');
if(!val)return;
if(navigator.clipboard){navigator.clipboard.writeText(val).catch(function(){});}
else{var ta=document.createElement('textarea');ta.value=val;document.body.appendChild(ta);ta.select();try{document.execCommand('copy');}catch(e){}document.body.removeChild(ta);}
chip.classList.add('chip-copied-flash');
setTimeout(function(){chip.classList.remove('chip-copied-flash');},900);
});
});
})();
// ── Shared tooltip for all result-page charts ─────────────────────────
var rTT=(function(){
var el=document.getElementById('r-tt');
if(!el)return{s:function(){},h:function(){},m:function(){}};
function show(e,html){el.innerHTML=html;el.style.display='block';move(e);}
function hide(){el.style.display='none';}
function move(e){
var x=e.clientX+16,y=e.clientY-12;
var r=el.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;
el.style.left=x+'px';el.style.top=y+'px';
}
return{s:show,h:hide,m:move};
})();
window.rTT=rTT;
// ── Tooltip event delegation (CSP-safe, no inline handlers needed) ────
(function(){
function escH(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
document.addEventListener('mouseover',function(e){
var t=e.target;
while(t&&t.getAttribute){
var l=t.getAttribute('data-ttl');
if(l!==null){
var v=t.getAttribute('data-ttv')||'';
rTT.s(e,'<strong>'+escH(l)+'</strong><br>'+escH(v));
return;
}
t=t.parentNode;
}
});
document.addEventListener('mouseout',function(e){
var t=e.target;
while(t&&t.getAttribute){
if(t.getAttribute('data-ttl')!==null){rTT.h();return;}
t=t.parentNode;
}
});
document.addEventListener('mousemove',function(e){
var el=document.getElementById('r-tt');
if(el&&el.style.display!=='none')rTT.m(e);
});
window.addEventListener('blur',function(){rTT.h();});
document.addEventListener('visibilitychange',function(){if(document.hidden)rTT.h();});
})();
// ── Language overview charts ───────────────────────────────────────────
(function(){
var D={{ lang_chart_json|safe }};
if(!D||!D.length)return;
var el=document.getElementById('result-lang-charts');
if(!el)return;
var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
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(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}
function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
function px(n){return Math.round(n);}
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+'"';}
var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
// Donut chart — height matches the stacked-bar chart so both panels align
var rHb_d=28;
var DH=Math.max(220,D.length*rHb_d+32);
var cx=100,cy=Math.round(DH/2),Ro=88,Ri=48;
var legX=204,DW=360;
var legCount=D.length;
var legSpacing=Math.max(12,Math.min(22,Math.floor((DH-30)/Math.max(legCount,1))));
var legYStart=Math.round((DH-legCount*legSpacing)/2);
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">';
if(D.length===1){
var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
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+'"/>';
} else {
var ang=-Math.PI/2;
D.forEach(function(d,i){
var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
var pct=Math.round(d.code/tot*100);
ds+='<path'+tt(d.lang,fmt(d.code)+' code lines ('+pct+'%)')+' data-lang="'+esc(d.lang)+'" 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"/>';
ang+=sw;
});
}
ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
D.forEach(function(d,i){
var ly=legYStart+i*legSpacing;
var pctL=Math.round(d.code/tot*100);
var ttL=String(d.lang).replace(/&/g,'&').replace(/"/g,'"');
var ttV=(fmt(d.code)+' code lines ('+pctL+'%)').replace(/&/g,'&').replace(/"/g,'"');
ds+='<g data-lang="'+esc(d.lang)+'" data-ttl="'+ttL+'" data-ttv="'+ttV+'" style="cursor:pointer;">';
ds+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+(legSpacing||14)+'" fill="transparent"/>';
ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(COLS[i%COLS.length])+'"/>';
ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(11,legSpacing-2)+'" fill="#43342d">'+esc(d.lang)+'</text>';
ds+='</g>';
});
ds+='</svg>';
// Horizontal stacked-bar chart — fills container width
var maxT=Math.max.apply(null,D.map(function(d){return d.physical||d.code+d.comments+d.blanks;}))||1;
var LW=108,BW=260,rHb=28,bH=20,SH=D.length*rHb+32,svgW=LW+BW+68;
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">';
D.forEach(function(d,i){
var y=6+i*rHb,x=LW;
var phys=d.physical||d.code+d.comments+d.blanks;
var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
bs+='<g class="lang-bar-row">';
bs+='<rect x="0" y="'+y+'" width="'+svgW+'" height="'+bH+'" fill="transparent"/>';
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>';
if(cW>0.5)bs+='<rect'+tt(d.lang+' Code',fmt(d.code)+' lines')+' data-kind="code" x="'+px(x)+'" y="'+y+'" width="'+px(cW)+'" height="'+bH+'" fill="'+OX+'" rx="0"/>';x+=cW;
if(cmW>0.5)bs+='<rect'+tt(d.lang+' Comments',fmt(d.comments)+' lines')+' data-kind="comment" x="'+px(x)+'" y="'+y+'" width="'+px(cmW)+'" height="'+bH+'" fill="'+GN+'" rx="0"/>';x+=cmW;
if(blW>0.5)bs+='<rect'+tt(d.lang+' Blank',fmt(d.blanks)+' lines')+' data-kind="blank" x="'+px(x)+'" y="'+y+'" width="'+px(blW)+'" height="'+bH+'" fill="'+GY+'" rx="0"/>';
bs+='<text x="'+(LW+BW+5)+'" y="'+(y+bH/2+4)+'" font-family="'+FONT+'" font-size="11" fill="#7b675b">'+fmt(phys)+'</text>';
bs+='</g>';
});
var ly=SH-14;
var totC=D.reduce(function(a,d){return a+(d.code||0);},0);
var totCm=D.reduce(function(a,d){return a+(d.comments||0);},0);
var totBl=D.reduce(function(a,d){return a+(d.blanks||0);},0);
var totAll=totC+totCm+totBl||1;
function legTT(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'"')+'"';}
var ttC=legTT('Code lines',fmt(totC)+' total ('+Math.round(totC/totAll*100)+'%)');
var ttCm=legTT('Comment lines',fmt(totCm)+' total ('+Math.round(totCm/totAll*100)+'%)');
var ttBl=legTT('Blank lines',fmt(totBl)+' total ('+Math.round(totBl/totAll*100)+'%)');
bs+='<g data-kind="code" style="cursor:pointer;">'
+'<rect x="'+LW+'" y="'+(ly-3)+'" width="52" height="16" fill="transparent"'+ttC+'/>'
+'<rect x="'+LW+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC+'/>'
+'<text x="'+(LW+13)+'" y="'+(ly+9)+'"'+ttC+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Code</text>'
+'</g>';
bs+='<g data-kind="comment" style="cursor:pointer;">'
+'<rect x="'+(LW+54)+'" y="'+(ly-3)+'" width="90" height="16" fill="transparent"'+ttCm+'/>'
+'<rect x="'+(LW+54)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm+'/>'
+'<text x="'+(LW+67)+'" y="'+(ly+9)+'"'+ttCm+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Comments</text>'
+'</g>';
bs+='<g data-kind="blank" style="cursor:pointer;">'
+'<rect x="'+(LW+152)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl+'/>'
+'<rect x="'+(LW+152)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl+'/>'
+'<text x="'+(LW+165)+'" y="'+(ly+9)+'"'+ttBl+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Blanks</text>'
+'</g>';
bs+='</svg>';
el.innerHTML='<div class="r-lang-overview">'+
'<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
'<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
'</div>';
function wireDonutLegend(svg){
if(!svg)return;
var paths=svg.querySelectorAll('path[data-lang]');
function hl(lang){for(var i=0;i<paths.length;i++){if(paths[i].getAttribute('data-lang')===lang){paths[i].style.filter='brightness(1.18) drop-shadow(0 2px 8px rgba(0,0,0,.25))';paths[i].style.transform='scale(1.05)';paths[i].style.opacity='1';}else{paths[i].style.opacity='0.32';paths[i].style.filter='none';paths[i].style.transform='none';}}}
function rst(){for(var i=0;i<paths.length;i++){paths[i].style.opacity='';paths[i].style.filter='';paths[i].style.transform='';}}
svg.addEventListener('mouseover',function(e){var t=e.target;while(t&&t!==svg){var l=t.getAttribute&&t.getAttribute('data-lang');if(l){hl(l);return;}t=t.parentNode;}});
svg.addEventListener('mouseout',function(e){if(e.relatedTarget&&svg.contains(e.relatedTarget))return;rst();});
}
function wireMixLegend(svg){
if(!svg)return;
var legGs=svg.querySelectorAll('g[data-kind]');
var allRects=svg.querySelectorAll('rect[data-kind]');
if(!legGs.length)return;
function hlKind(kind){for(var i=0;i<allRects.length;i++){var r=allRects[i];if(r.getAttribute('data-kind')===kind){r.style.opacity='1';r.style.filter='brightness(1.18) drop-shadow(0 2px 6px rgba(0,0,0,.22))';}else{r.style.opacity='0.18';r.style.filter='none';}}for(var j=0;j<legGs.length;j++){legGs[j].style.opacity=legGs[j].getAttribute('data-kind')===kind?'1':'0.45';}}
function rst(){for(var i=0;i<allRects.length;i++){allRects[i].style.opacity='';allRects[i].style.filter='';}for(var j=0;j<legGs.length;j++){legGs[j].style.opacity='';}}
for(var k=0;k<legGs.length;k++){(function(g){g.addEventListener('mouseenter',function(){hlKind(g.getAttribute('data-kind'));});g.addEventListener('mouseleave',rst);})(legGs[k]);}
}
wireDonutLegend(el.querySelector('svg'));
wireMixLegend(el.querySelectorAll('svg')[1]);
// ── Language breakdown Full View expand ─────────────────────────────────
var langOvBtn=document.getElementById('result-lang-overview-expand');
if(langOvBtn){langOvBtn.addEventListener('click',function(){
var src=document.getElementById('result-lang-charts');
if(!src)return;
var overlay=document.createElement('div');
overlay.className='r-chart-modal-overlay';
overlay.innerHTML='<div class="r-chart-modal" style="max-width:1600px;"><button class="r-chart-modal-close" aria-label="Close">×</button><div class="r-modal-header"><span class="r-chart-modal-title">Language Breakdown — Full View</span></div><div id="result-lang-overview-modal-wrap" style="width:100%;"></div></div>';
document.body.appendChild(overlay);
overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
var wrap=document.getElementById('result-lang-overview-modal-wrap');
if(wrap){
wrap.innerHTML=src.innerHTML;
var svgs=wrap.querySelectorAll('svg');
for(var i=0;i<svgs.length;i++){
svgs[i].removeAttribute('width');
svgs[i].removeAttribute('height');
svgs[i].style.cssText='display:block;width:100%;height:auto;';
}
var ov=wrap.querySelector('.r-lang-overview');
if(ov){ov.style.flexWrap='nowrap';ov.style.alignItems='stretch';}
var cells=wrap.querySelectorAll('.r-lang-overview-cell');
if(cells.length>0)cells[0].style.cssText='flex:1 1 0;max-width:none;justify-content:center;';
if(cells.length>1)cells[1].style.cssText='flex:1 1 0;max-width:none;';
wireDonutLegend(wrap.querySelector('svg'));
wireMixLegend(wrap.querySelectorAll('svg')[1]);
requestAnimationFrame(function(){
var ss=wrap.querySelectorAll('svg');
if(ss.length>=2){var bh=ss[1].getBoundingClientRect().height;if(bh>0){ss[0].style.cssText='display:block;height:'+bh+'px;width:auto;max-width:100%;';}}
});
}
});}
})();
// ── Extended charts (composition, scatter, semantic, submodule) ─────────
(function(){
var LANG_D={{ lang_chart_json|safe }};
var SCAT_D={{ scatter_chart_json|safe }};
var SEM_D={{ semantic_chart_json|safe }};
var SUB_D={{ submodule_chart_json|safe }};
var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#1F6E6E','#8B4513','#4169E1','#228B22','#8B008B','#FF6347','#708090','#DAA520'];
var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
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(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}
function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
function px(n){return Math.round(n);}
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+'"';}
// ── Composition (horizontal stacked bars, abs or 100% pct) ────────────
function renderCompositionInEl(el,mode,shOvr){
if(!el||!LANG_D||!LANG_D.length)return;
var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
var LW=110,SH=shOvr||224;
var svgW=Math.max(320,el.offsetWidth||480);
var BW=Math.max(120,svgW-LW-80);
var legendH=24,topPad=4;
var n=LANG_D.length||1;
var rowTotal=Math.floor((SH-legendH-topPad)/n);
var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
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">';
var totC2=LANG_D.reduce(function(a,d){return a+(d.code||0);},0);
var totCm2=LANG_D.reduce(function(a,d){return a+(d.comments||0);},0);
var totBl2=LANG_D.reduce(function(a,d){return a+(d.blanks||0);},0);
var totAll2=totC2+totCm2+totBl2||1;
if(mode==='pct'){
LANG_D.forEach(function(d,i){
var tot2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
var cW=(d.code||0)/tot2*BW,cmW=(d.comments||0)/tot2*BW,blW=(d.blanks||0)/tot2*BW;
var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
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>';
if(cW>0.5)s+='<rect'+tt(d.lang+' Code',fmt(d.code||0)+' lines')+' data-kind="code" x="'+px(x)+'" y="'+y+'" width="'+px(cW)+'" height="'+bH+'" fill="'+OX+'"/>';x+=cW;
if(cmW>0.5)s+='<rect'+tt(d.lang+' Comments',fmt(d.comments||0)+' lines')+' data-kind="comment" x="'+px(x)+'" y="'+y+'" width="'+px(cmW)+'" height="'+bH+'" fill="'+GN+'"/>';x+=cmW;
if(blW>0.5)s+='<rect'+tt(d.lang+' Blank',fmt(d.blanks||0)+' lines')+' data-kind="blank" x="'+px(x)+'" y="'+y+'" width="'+px(blW)+'" height="'+bH+'" fill="'+GY+'"/>';
var pct=Math.round((d.code||0)/tot2*100);
s+='<text x="'+(LW+BW+4)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="currentColor">'+pct+'%</text>';
});
} else {
var maxT=Math.max.apply(null,LANG_D.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);}))||1;
LANG_D.forEach(function(d,i){
var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
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>';
if(cW>0.5)s+='<rect'+tt(d.lang+' Code',fmt(d.code||0)+' lines')+' data-kind="code" x="'+px(x)+'" y="'+y+'" width="'+px(cW)+'" height="'+bH+'" fill="'+OX+'"/>';x+=cW;
if(cmW>0.5)s+='<rect'+tt(d.lang+' Comments',fmt(d.comments||0)+' lines')+' data-kind="comment" x="'+px(x)+'" y="'+y+'" width="'+px(cmW)+'" height="'+bH+'" fill="'+GN+'"/>';x+=cmW;
if(blW>0.5)s+='<rect'+tt(d.lang+' Blank',fmt(d.blanks||0)+' lines')+' data-kind="blank" x="'+px(x)+'" y="'+y+'" width="'+px(blW)+'" height="'+bH+'" fill="'+GY+'"/>';
s+='<text x="'+(LW+cW+cmW+blW+4)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="currentColor">'+fmt(d.physical||(d.code||0)+(d.comments||0)+(d.blanks||0))+'</text>';
});
}
var ly=SH-legendH+4;
function legTT2(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'"')+'"';}
var ttC2=legTT2('Code lines',fmt(totC2)+' total ('+Math.round(totC2/totAll2*100)+'%)');
var ttCm2=legTT2('Comment lines',fmt(totCm2)+' total ('+Math.round(totCm2/totAll2*100)+'%)');
var ttBl2=legTT2('Blank lines',fmt(totBl2)+' total ('+Math.round(totBl2/totAll2*100)+'%)');
s+='<g data-kind="code" style="cursor:pointer;">'
+'<rect x="'+LW+'" y="'+(ly-3)+'" width="52" height="16" fill="transparent"'+ttC2+'/>'
+'<rect x="'+LW+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC2+'/>'
+'<text x="'+(LW+13)+'" y="'+(ly+9)+'"'+ttC2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Code</text>'
+'</g>';
s+='<g data-kind="comment" style="cursor:pointer;">'
+'<rect x="'+(LW+53)+'" y="'+(ly-3)+'" width="90" height="16" fill="transparent"'+ttCm2+'/>'
+'<rect x="'+(LW+53)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm2+'/>'
+'<text x="'+(LW+66)+'" y="'+(ly+9)+'"'+ttCm2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Comments</text>'
+'</g>';
s+='<g data-kind="blank" style="cursor:pointer;">'
+'<rect x="'+(LW+152)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl2+'/>'
+'<rect x="'+(LW+152)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl2+'/>'
+'<text x="'+(LW+165)+'" y="'+(ly+9)+'"'+ttBl2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Blank</text>'
+'</g>';
s+='</svg>';
el.innerHTML=s;
wireMixLegendEl(el);
}
function wireMixLegendEl(container){
var svg=container&&container.querySelector('svg');
if(!svg)return;
var legGs=svg.querySelectorAll('g[data-kind]');
var allRects=svg.querySelectorAll('rect[data-kind]');
if(!legGs.length)return;
function hlKind(kind){for(var i=0;i<allRects.length;i++){var r=allRects[i];if(r.getAttribute('data-kind')===kind){r.style.opacity='1';r.style.filter='brightness(1.18) drop-shadow(0 2px 6px rgba(0,0,0,.22))';}else{r.style.opacity='0.18';r.style.filter='none';}}for(var j=0;j<legGs.length;j++){legGs[j].style.opacity=legGs[j].getAttribute('data-kind')===kind?'1':'0.45';}}
function rst(){for(var i=0;i<allRects.length;i++){allRects[i].style.opacity='';allRects[i].style.filter='';}for(var j=0;j<legGs.length;j++){legGs[j].style.opacity='';}}
for(var k=0;k<legGs.length;k++){(function(g){g.addEventListener('mouseenter',function(){hlKind(g.getAttribute('data-kind'));});g.addEventListener('mouseleave',rst);})(legGs[k]);}
}
function renderComposition(mode){renderCompositionInEl(document.getElementById('r-composition-chart'),mode,0);}
renderComposition('abs');
Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(btn){
btn.addEventListener('click',function(){
Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(b){b.classList.remove('active');});
btn.classList.add('active');
renderComposition(btn.getAttribute('data-rcomp'));
});
});
// ── Scatter: Files vs Code Lines (bubble = physical lines) ─────────────
function renderScatterInEl(el,hOvr){
if(!el||!SCAT_D||!SCAT_D.length)return;
var H=hOvr||224,PL=52,PB=36,PT=12,PR=14;
var W=Math.max(320,el.offsetWidth||480);
var cW=W-PL-PR,cH=H-PT-PB;
var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
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">';
[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>';
});
[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>';
});
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>';
});
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>';
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>';
s+='</svg>';
el.innerHTML=s;
}
renderScatterInEl(document.getElementById('r-scatter-chart'),0);
// ── Semantic: horizontal bar chart (one bar per language) ─────────────
// Horizontal layout avoids the portrait-aspect scaling bug that plagued
// the old vertical column layout on wide containers.
function renderSemanticInEl(el,key,sh){
if(!el||!SEM_D||!SEM_D.length)return;
var n2=SEM_D.length||1;
var LW=112,SH=sh||Math.max(180,n2*28+26);
var svgW=Math.max(320,el.offsetWidth||480);
var BW=Math.max(120,svgW-LW-80);
var topPad=4,botPad=14;
var rowTotal2=Math.floor((SH-topPad-botPad)/n2);
var bH=Math.min(22,Math.max(10,Math.floor(rowTotal2*0.65)));
var maxV=Math.max.apply(null,SEM_D.map(function(d){return d[key]||0;}))||1;
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">';
SEM_D.forEach(function(d,i){
var v=d[key]||0,bw=v/maxV*BW,y=topPad+i*rowTotal2+Math.floor((rowTotal2-bH)/2);
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>';
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"/>';
s+='<text x="'+(LW+px(bw)+6)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="currentColor" style="pointer-events:none;">'+fmt(v)+'</text>';
});
s+='</svg>';
el.innerHTML=s;
}
function renderSemantic(key){renderSemanticInEl(document.getElementById('r-semantic-chart'),key,0);}
var semSel=document.getElementById('r-semantic-metric');
if(semSel){renderSemantic('functions');semSel.addEventListener('change',function(){renderSemantic(semSel.value);syncRowHeights();});}
var semExpand=document.getElementById('r-semantic-expand');
if(semExpand){
semExpand.addEventListener('click',function(){
var key=semSel?semSel.value:'functions';
var n=SEM_D.length||1;
var maxH=Math.max(360,Math.floor(window.innerHeight*0.82)-130);
var modalH=Math.min(Math.max(360,n*38+60),maxH);
var overlay=document.createElement('div');
overlay.className='r-chart-modal-overlay';
var optHtml=
'<option value="functions"'+(key==='functions'?' selected':'')+'>Functions</option>'
+'<option value="classes"'+(key==='classes'?' selected':'')+'>Classes</option>'
+'<option value="variables"'+(key==='variables'?' selected':'')+'>Variables</option>'
+'<option value="imports"'+(key==='imports'?' selected':'')+'>Imports</option>'
+'<option value="tests"'+(key==='tests'?' selected':'')+'>Tests</option>';
overlay.innerHTML='<div class="r-chart-modal" style="max-width:1320px;"><button class="r-chart-modal-close" aria-label="Close">×</button><div class="r-modal-header"><span class="r-chart-modal-title">Semantic Metrics — Full View</span><select class="r-chart-select" id="r-sem-modal-metric">'+optHtml+'</select></div><div id="r-sem-modal-chart" style="height:'+modalH+'px;width:100%;overflow:hidden;"></div></div>';
document.body.appendChild(overlay);
overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
var modalEl=document.getElementById('r-sem-modal-chart');
if(modalEl){setTimeout(function(){renderSemanticInEl(modalEl,key,modalH);},30);}
var modalSel=document.getElementById('r-sem-modal-metric');
if(modalSel){modalSel.addEventListener('change',function(){renderSemanticInEl(modalEl,modalSel.value,modalH);});}
});
}
// ── Expand buttons: re-render charts at large size inside modal ──────────
(function(){
function makeExpandModal(title,mH,subtitle,ctrlHtml){
var overlay=document.createElement('div');
overlay.className='r-chart-modal-overlay';
var subHtml=subtitle?'<span class="r-chart-modal-subtitle">'+subtitle+'</span>':'';
var hdr='<div class="r-modal-header"><span class="r-chart-modal-title">'+title+' — Full View</span>'+(ctrlHtml||'')+'</div>';
overlay.innerHTML='<div class="r-chart-modal" style="max-width:1320px;"><button class="r-chart-modal-close" aria-label="Close">×</button>'+hdr+subHtml+'<div class="r-expand-modal-chart" style="width:100%;height:'+mH+'px;overflow:hidden;"></div></div>';
document.body.appendChild(overlay);
overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
return overlay.querySelector('.r-expand-modal-chart');
}
function capH(h){return Math.min(h,Math.max(360,Math.floor(window.innerHeight*0.82)-130));}
var compExpandBtn=document.getElementById('r-composition-expand');
if(compExpandBtn){compExpandBtn.addEventListener('click',function(){
var mode=document.querySelector('[data-rcomp].active');var modeKey=mode?mode.getAttribute('data-rcomp'):'abs';
var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
var ctrlHtml='<button class="r-chart-tab'+(modeKey==='abs'?' active':'')+'" data-mcomp="abs">Absolute</button>'
+'<button class="r-chart-tab'+(modeKey==='pct'?' active':'')+'" data-mcomp="pct">100% Normalized</button>';
var wrap=makeExpandModal('Language Composition',mH,null,ctrlHtml);
if(wrap){
setTimeout(function(){renderCompositionInEl(wrap,modeKey,mH);},30);
Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(btn){
btn.addEventListener('click',function(){
Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(b){b.classList.remove('active');});
btn.classList.add('active');
renderCompositionInEl(wrap,btn.getAttribute('data-mcomp'),mH);
});
});
}
});}
var scatExpandBtn=document.getElementById('r-scatter-expand');
if(scatExpandBtn){scatExpandBtn.addEventListener('click',function(){
var wrap=makeExpandModal('Files vs Code Lines',capH(672),'File count vs SLOC per language');
if(wrap)setTimeout(function(){renderScatterInEl(wrap,560);},30);
});}
var densExpandBtn=document.getElementById('r-density-expand');
if(densExpandBtn){densExpandBtn.addEventListener('click',function(){
var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
var wrap=makeExpandModal('Comment Density',mH,'Comment ratio per language');
if(wrap)setTimeout(function(){renderDensityInEl(wrap,mH);},30);
});}
var avgExpandBtn=document.getElementById('r-avglines-expand');
if(avgExpandBtn){avgExpandBtn.addEventListener('click',function(){
var n=LANG_D.filter(function(d){return(d.files||0)>0;}).length||1;var mH=capH(Math.max(360,n*38+60));
var wrap=makeExpandModal('Avg Lines per File',mH,'Average code lines per file');
if(wrap)setTimeout(function(){renderAvgLinesInEl(wrap,mH);},30);
});}
var subExpandBtn=document.getElementById('r-submodule-expand');
if(subExpandBtn){subExpandBtn.addEventListener('click',function(){
var key=subSel?subSel.value:'code';var sort=sortSel?sortSel.value:'desc';
var n=(SUB_D.length+1)||1;var mH=capH(Math.max(360,n*32+100));
var metCtrl=
'<select class="r-chart-select" id="r-sub-modal-metric">'
+'<option value="code"'+(key==='code'?' selected':'')+'>Code Lines</option>'
+'<option value="comment"'+(key==='comment'?' selected':'')+'>Comments</option>'
+'<option value="blank"'+(key==='blank'?' selected':'')+'>Blank Lines</option>'
+'<option value="physical"'+(key==='physical'?' selected':'')+'>Physical Lines</option>'
+'<option value="files"'+(key==='files'?' selected':'')+'>Files</option>'
+'</select>';
var sortCtrl=
'<select class="r-chart-select" id="r-sub-modal-sort">'
+'<option value="desc"'+(sort==='desc'?' selected':'')+'>Value ↓</option>'
+'<option value="asc"'+(sort==='asc'?' selected':'')+'>Value ↑</option>'
+'<option value="name"'+(sort==='name'?' selected':'')+'>Name A→Z</option>'
+'</select>';
var wrap=makeExpandModal('Repository Overview',mH,null,metCtrl+sortCtrl);
if(wrap){
setTimeout(function(){renderSubmoduleInEl(wrap,key,sort,mH);},30);
var mSub=wrap.parentNode.querySelector('#r-sub-modal-metric');
var mSort=wrap.parentNode.querySelector('#r-sub-modal-sort');
function reRenderSub(){renderSubmoduleInEl(wrap,mSub?mSub.value:'code',mSort?mSort.value:'desc',mH);}
if(mSub)mSub.addEventListener('change',reRenderSub);
if(mSort)mSort.addEventListener('change',reRenderSub);
}
});}
})();
// ── Comment Density: comments / (code + comments) per language ───────────
function renderDensityInEl(el,shOvr){
if(!el||!LANG_D||!LANG_D.length)return;
var n=LANG_D.length||1;
var LW=112,SH=shOvr||Math.max(180,n*28+26);
var svgW=Math.max(320,el.offsetWidth||480);
var BW=Math.max(120,svgW-LW-80);
var topPad=4,botPad=26;
var rowTotal=Math.floor((SH-topPad-botPad)/n);
var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
var densities=LANG_D.map(function(d){
var sig=(d.code||0)+(d.comments||0);
return sig>0?(d.comments||0)/sig:0;
});
var maxDen=Math.max.apply(null,densities)||1;
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">';
LANG_D.forEach(function(d,i){
var den=densities[i],bw=den/maxDen*BW;
var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
var pct=Math.round(den*100);
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>';
if(bw>0.5)s+='<rect'+tt(d.lang,pct+'% of significant lines are comments')+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+COLS[i%COLS.length]+'" rx="3"/>';
else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
s+='<text x="'+(LW+Math.max(px(bw),2)+6)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="currentColor" style="pointer-events:none;">'+pct+'%</text>';
});
s+='<text x="'+(LW+BW/2)+'" y="'+(SH-6)+'" text-anchor="middle" font-family="'+FONT+'" font-size="12" fill="currentColor" opacity="0.75">comment ratio (higher = more documented)</text>';
s+='</svg>';
el.innerHTML=s;
}
function renderDensity(){renderDensityInEl(document.getElementById('r-density-chart'),0);}
renderDensity();
// ── Avg Lines per File: code / files per language ─────────────────────
function renderAvgLinesInEl(el,shOvr){
if(!el||!LANG_D||!LANG_D.length)return;
var data=LANG_D.filter(function(d){return(d.files||0)>0;}).slice();
data.sort(function(a,b){return(b.code/b.files)-(a.code/a.files);});
var n=data.length||1;
var LW=112,SH=shOvr||Math.max(180,n*28+26);
var svgW=Math.max(320,el.offsetWidth||480);
var BW=Math.max(120,svgW-LW-80);
var topPad=4,botPad=26;
var rowTotal=Math.floor((SH-topPad-botPad)/n);
var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
var avgs=data.map(function(d){return(d.code||0)/(d.files||1);});
var maxAvg=Math.max.apply(null,avgs)||1;
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">';
data.forEach(function(d,i){
var avg=avgs[i],bw=avg/maxAvg*BW;
var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
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>';
if(bw>0.5)s+='<rect'+tt(d.lang,fmt(Math.round(avg))+' avg code lines/file · '+fmt(d.files||0)+' files')+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+COLS[i%COLS.length]+'" rx="3"/>';
else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
s+='<text x="'+(LW+Math.max(px(bw),2)+6)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="currentColor" style="pointer-events:none;">'+fmt(Math.round(avg))+'</text>';
});
s+='<text x="'+(LW+BW/2)+'" y="'+(SH-6)+'" text-anchor="middle" font-family="'+FONT+'" font-size="12" fill="currentColor" opacity="0.75">avg code lines per file (higher = larger files)</text>';
s+='</svg>';
el.innerHTML=s;
}
function renderAvgLines(){renderAvgLinesInEl(document.getElementById('r-avglines-chart'),0);}
renderAvgLines();
// ── Repository Overview: overall row + per-submodule rows ────────────
function renderSubmoduleInEl(el,key,sort,shOvr){
if(!el)return;
var overall={
name:'Overall',
code:{{ code_lines }},
comment:{{ comment_lines }},
blank:{{ blank_lines }},
physical:{{ physical_lines }},
files:{{ files_analyzed }},
isOverall:true
};
var subs=SUB_D.slice();
if(sort==='desc')subs.sort(function(a,b){return(b[key]||0)-(a[key]||0);});
else if(sort==='asc')subs.sort(function(a,b){return(a[key]||0)-(b[key]||0);});
else subs.sort(function(a,b){return(a.name||'').localeCompare(b.name||'');});
var data=[overall].concat(subs);
var rowH=32,bH=22,sepH=subs.length>0?14:0;
var SH=shOvr||Math.max(80,data.length*rowH+sepH+16);
var svgW=Math.max(320,el.offsetWidth||480);
var LW=116,BW=Math.max(200,svgW-LW-54);
var maxV=Math.max.apply(null,data.map(function(d){return d[key]||0;}))||1;
var OVERALL_COL='#6b7280';
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">';
var yOff=4;
data.forEach(function(d,i){
var v=d[key]||0,bw=v/maxV*BW,y=yOff;
var col=d.isOverall?OVERALL_COL:COLS[(i-1)%COLS.length];
var label=d.name||d.path||'?';
s+='<text x="'+(LW-5)+'" y="'+(y+bH/2+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="currentColor"'+(d.isOverall?' font-weight="700"':'')+'>'+esc(label)+'</text>';
if(bw>0.5)s+='<rect'+tt(label,fmt(v))+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+col+'" rx="3"/>';
else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
s+='<text x="'+(LW+Math.max(px(bw),2)+6)+'" y="'+(y+bH/2+4)+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="currentColor" style="pointer-events:none;">'+fmt(v)+'</text>';
yOff+=rowH;
if(d.isOverall&&subs.length>0){
yOff+=sepH;
}
});
s+='</svg>';
el.innerHTML=s;
}
function renderSubmodule(key,sort){renderSubmoduleInEl(document.getElementById('r-submodule-chart'),key,sort,0);}
var subSel=document.getElementById('r-sub-metric');
var sortSel=document.getElementById('r-sub-sort');
renderSubmodule('code','desc');
if(subSel){
subSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel?sortSel.value:'desc');syncRowHeights();});
if(sortSel)sortSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel.value);syncRowHeights();});
}
// Equalise heights within each chart row: if one chart in a grid row is taller
// than its neighbour, re-render the shorter one at the taller height so bars fill
// the available vertical space instead of leaving a gap.
function syncRowHeights(){
var avgEl=document.getElementById('r-avglines-chart');
var subEl=document.getElementById('r-submodule-chart');
if(avgEl&&subEl){
var avgSvg=avgEl.querySelector('svg');
var subSvg=subEl.querySelector('svg');
if(avgSvg&&subSvg){
var avgH=parseInt(avgSvg.getAttribute('height')||'0',10);
var subH=parseInt(subSvg.getAttribute('height')||'0',10);
var key=subSel?subSel.value||'code':'code';
var sort=sortSel?sortSel.value:'desc';
if(subH>avgH+10){renderAvgLinesInEl(avgEl,subH);}
else if(avgH>subH+10){renderSubmoduleInEl(subEl,key,sort,avgH);}
}
}
var semEl=document.getElementById('r-semantic-chart');
var denEl=document.getElementById('r-density-chart');
if(semEl&&denEl){
var semSvg=semEl.querySelector('svg');
var denSvg=denEl.querySelector('svg');
if(semSvg&&denSvg){
var semH2=parseInt(semSvg.getAttribute('height')||'0',10);
var denH2=parseInt(denSvg.getAttribute('height')||'0',10);
if(denH2>semH2+10){renderSemanticInEl(semEl,semSel?semSel.value:'functions',denH2);}
else if(semH2>denH2+10){renderDensityInEl(denEl,semH2);}
}
}
}
syncRowHeights();
// Re-render all SVG charts when the window is resized so bars fill the card.
var _rResizeTimer;
window.addEventListener('resize',function(){
clearTimeout(_rResizeTimer);
_rResizeTimer=setTimeout(function(){
var rcompBtn=document.querySelector('[data-rcomp].active');
renderComposition(rcompBtn?rcompBtn.getAttribute('data-rcomp'):'abs');
renderScatterInEl(document.getElementById('r-scatter-chart'),0);
if(semSel)renderSemantic(semSel.value||'functions');
renderDensity();
renderAvgLines();
renderSubmodule(subSel?subSel.value||'code':'code',sortSel?sortSel.value:'desc');
syncRowHeights();
},120);
});
})();
(function randomizeWatermarks() {
var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
if (!wms.length) return;
var placed = [];
function tooClose(top, left) {
for (var i = 0; i < placed.length; i++) {
var dt = Math.abs(placed[i][0] - top);
var dl = Math.abs(placed[i][1] - left);
if (dt < 20 && dl < 18) return true;
}
return false;
}
function pick(leftBand) {
for (var attempt = 0; attempt < 50; attempt++) {
var top = Math.random() * 85 + 5;
var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
}
var top = Math.random() * 85 + 5;
var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
placed.push([top, left]);
return [top, left];
}
var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
var half = Math.floor(wms.length / 2);
wms.forEach(function (img, i) {
var pos = pick(i < half);
var size = Math.floor(Math.random() * 100 + 160);
var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
var op = (Math.random() * 0.06 + 0.07).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.toFixed(1)+"deg)";img.style.opacity=op;
});
})();
(function spawnCodeParticles() {
var container = document.getElementById('code-particles');
if (!container) return;
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'];
for (var i = 0; i < 38; 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);
}
})();
{% if pdf_generating %}
// Poll for PDF readiness and swap the disabled button to a live link once done.
(function() {
var openBtn = document.getElementById('pdf-open-btn');
var dlBtn = document.getElementById('pdf-download-btn');
function checkPdf() {
fetch('/api/runs/{{ run_id }}/pdf-status')
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.ready) {
if (openBtn) {
var a = document.createElement('a');
a.className = 'button';
a.id = 'pdf-open-btn';
a.href = '/runs/pdf/{{ run_id }}';
a.target = '_blank';
a.rel = 'noopener';
a.textContent = 'Open PDF';
openBtn.replaceWith(a);
}
if (dlBtn) { dlBtn.style.opacity = ''; dlBtn.style.pointerEvents = ''; }
} else {
setTimeout(checkPdf, 3000);
}
})
.catch(function() { setTimeout(checkPdf, 5000); });
}
setTimeout(checkPdf, 3000);
})();
{% endif %}
})();
</script>
<script nonce="{{ csp_nonce }}">
(function(){
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'}];
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);});}
try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
function init(){
var btn=document.getElementById('settings-btn');if(!btn)return;
var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
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>';
document.body.appendChild(m);
var g=document.getElementById('scheme-grid');
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);});
var cl=document.getElementById('settings-close');
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);
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');});
if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
}
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
}());
</script>
<footer class="site-footer">
local code analysis - metrics, history and reports
· <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
· Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
· <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
· <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
· <a href="/api-docs" rel="noopener">REST API</a>
</footer>
{% if confluence_configured %}
<script nonce="{{ csp_nonce }}">
(function() {
var postBtn = document.getElementById('postConfluenceBtn');
var copyBtn = document.getElementById('copyWikiBtn');
var modal = document.getElementById('confluenceModal');
if (!postBtn || !modal) return;
postBtn.addEventListener('click', function() {
document.getElementById('confStatus').style.display = 'none';
modal.style.display = 'flex';
});
document.getElementById('confCancelBtn').addEventListener('click', function() {
modal.style.display = 'none';
});
modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
document.getElementById('confSubmitBtn').addEventListener('click', async function() {
var btn = this;
btn.disabled = true;
var status = document.getElementById('confStatus');
status.style.display = 'block';
status.style.background = '#dbeafe';
status.style.color = '#1e40af';
status.textContent = 'Posting to Confluence…';
var resp = await fetch('/api/confluence/post', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
run_id: '{{ run_id }}',
page_title: document.getElementById('confPageTitle').value.trim() || 'OxideSLOC Report',
report_url: document.getElementById('confReportUrl').value.trim() || null
})
});
var data = await resp.json();
if (data.ok) {
status.style.background = '#dcfce7'; status.style.color = '#166534';
status.textContent = 'Posted! Page ID: ' + data.page_id;
} else {
status.style.background = '#fee2e2'; status.style.color = '#991b1b';
status.textContent = 'Error: ' + (data.error || 'Unknown error');
}
btn.disabled = false;
});
if (copyBtn) {
copyBtn.addEventListener('click', async function() {
var resp = await fetch('/api/confluence/wiki-markup?run_id={{ run_id }}');
if (!resp.ok) { alert('Could not load markup. Try again.'); return; }
var text = await resp.text();
try {
await navigator.clipboard.writeText(text);
var orig = copyBtn.textContent;
copyBtn.textContent = 'Copied!';
setTimeout(function() { copyBtn.textContent = orig; }, 2000);
} catch(e) {
alert('Clipboard write failed — check browser permissions.');
}
});
}
})();
</script>
{% endif %}
<script nonce="{{ csp_nonce }}">
(function() {
var deleteBtn = document.getElementById('delete-run-btn');
var modal = document.getElementById('delete-run-modal');
var cancelBtn = document.getElementById('delete-run-cancel');
var confirmBtn= document.getElementById('delete-run-confirm');
if (!deleteBtn || !modal) return;
deleteBtn.addEventListener('click', function() {
document.getElementById('delete-run-status').style.display = 'none';
modal.style.display = 'flex';
});
cancelBtn.addEventListener('click', function() { modal.style.display = 'none'; });
modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
confirmBtn.addEventListener('click', async function() {
confirmBtn.disabled = true;
cancelBtn.disabled = true;
var status = document.getElementById('delete-run-status');
status.style.display = 'block';
status.style.background = '#dbeafe'; status.style.color = '#1e40af';
status.textContent = 'Deleting…';
try {
var resp = await fetch('/api/runs/{{ run_id }}', { method: 'DELETE' });
if (resp.status === 204 || resp.ok) {
status.style.background = '#dcfce7'; status.style.color = '#166534';
status.textContent = 'Deleted. Redirecting…';
setTimeout(function() { window.location.href = '/view-reports'; }, 1200);
} else {
var d = await resp.json().catch(function(){return {};});
status.style.background = '#fee2e2'; status.style.color = '#991b1b';
status.textContent = 'Error: ' + (d.error || 'Unexpected server error');
confirmBtn.disabled = false;
cancelBtn.disabled = false;
}
} catch (e) {
status.style.background = '#fee2e2'; status.style.color = '#991b1b';
status.textContent = 'Network error: ' + String(e);
confirmBtn.disabled = false;
cancelBtn.disabled = false;
}
});
})();
</script>
<script nonce="{{ csp_nonce }}">(function(){
var bundleBtn = document.getElementById('download-bundle-btn');
if (bundleBtn) {
bundleBtn.addEventListener('click', function() {
bundleBtn.disabled = true;
var orig = bundleBtn.textContent;
bundleBtn.textContent = 'Preparing…';
fetch('/api/runs/{{ run_id }}/bundle')
.then(function(r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.blob();
})
.then(function(blob) {
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'oxide-sloc-{{ run_id }}.tar.gz';
document.body.appendChild(a);
a.click();
setTimeout(function() { URL.revokeObjectURL(url); document.body.removeChild(a); }, 5000);
bundleBtn.disabled = false;
bundleBtn.textContent = orig;
})
.catch(function(e) {
bundleBtn.disabled = false;
bundleBtn.textContent = orig;
alert('Bundle download failed: ' + String(e));
});
});
}
})();</script>
<script nonce="{{ csp_nonce }}">(function(){
var dot=document.getElementById('status-dot');
var pingEl=document.getElementById('server-ping-ms');
var tipEl=document.getElementById('server-tip-ping');
var fm=document.getElementById('footer-mode');
function setDotColor(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}
function doPing(){
var t0=performance.now();
fetch('/healthz',{cache:'no-store'})
.then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDotColor(ms);})
.catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});
}
doPing();
setInterval(doPing,5000);
if(fm){var isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';fm.textContent='oxide-sloc v{{ version }} — Mode: '+(isServer?'Network Server':'Local');}
})();</script>
{% if let Some(banner) = report_header_footer %}
<div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
{% endif %}
</body>
</html>
"##,
ext = "html"
)]
// Template structs need many bool fields to pass Askama rendering flags.
#[allow(clippy::struct_excessive_bools)]
struct ResultTemplate {
version: &'static str,
report_title: String,
project_path: String,
output_dir: String,
run_id: String,
files_analyzed: u64,
files_skipped: u64,
physical_lines: u64,
code_lines: u64,
comment_lines: u64,
blank_lines: u64,
mixed_lines: u64,
functions: u64,
classes: u64,
variables: u64,
imports: u64,
html_url: Option<String>,
pdf_url: Option<String>,
json_url: Option<String>,
html_download_url: Option<String>,
pdf_download_url: Option<String>,
json_download_url: Option<String>,
html_path: Option<String>,
json_path: Option<String>,
prev_run_id: Option<String>,
prev_run_timestamp: Option<String>,
prev_run_code_lines: Option<u64>,
// Previous scan summary columns (pre-formatted; "—" when no prior scan)
prev_fa_str: String,
prev_fs_str: String,
prev_pl_str: String,
prev_cl_str: String,
prev_cml_str: String,
prev_bl_str: String,
// Signed change column for main metrics
delta_fa_str: String,
delta_fa_class: String,
delta_fs_str: String,
delta_fs_class: String,
delta_pl_str: String,
delta_pl_class: String,
delta_cl_str: String,
delta_cl_class: String,
delta_cml_str: String,
delta_cml_class: String,
delta_bl_str: String,
delta_bl_class: String,
// delta vs previous scan
delta_lines_added: Option<i64>,
delta_lines_removed: Option<i64>,
delta_lines_net_str: String,
delta_lines_net_class: String,
delta_files_added: Option<usize>,
delta_files_removed: Option<usize>,
delta_files_modified: Option<usize>,
delta_files_unchanged: Option<usize>,
delta_unmodified_lines: Option<u64>,
// git context
git_branch: Option<String>,
git_branch_url: Option<String>,
git_commit: Option<String>,
git_commit_long: Option<String>,
git_author: Option<String>,
git_commit_url: Option<String>,
// scan metadata for hero section
scan_performed_by: String,
scan_time_display: String,
os_display: String,
test_count: u64,
// history
prev_scan_count: usize,
current_scan_number: usize,
// submodule breakdown (empty when not requested)
submodule_rows: Vec<SubmoduleRow>,
scan_config_url: String,
lang_chart_json: String,
// Askama reads these via proc-macro expansion; clippy can't trace through it.
#[allow(dead_code)]
scatter_chart_json: String,
#[allow(dead_code)]
semantic_chart_json: String,
#[allow(dead_code)]
submodule_chart_json: String,
#[allow(dead_code)]
has_submodule_data: bool,
#[allow(dead_code)]
has_semantic_data: bool,
pdf_generating: bool,
csp_nonce: String,
/// Whether Confluence integration is configured — shows Post button when true.
confluence_configured: bool,
server_mode: bool,
/// Header/footer identification banner, mirrored from the HTML/PDF report.
report_header_footer: Option<String>,
run_id_short: String,
/// True when rendering a static offline file (index.html); hides server-only actions.
#[allow(dead_code)]
is_offline: bool,
/// Total cyclomatic complexity score across all analyzed files.
cyclomatic_complexity: u64,
/// Logical SLOC (statement count) when available; None for unsupported languages.
lsloc: Option<u64>,
/// Unique Lines of Code across all analyzed files.
uloc: u64,
/// Pre-formatted `DRYness` percentage string (e.g. "82.3") or empty when not available.
dryness_pct_str: String,
/// Number of duplicate file groups detected.
duplicate_group_count: usize,
/// Whether a COCOMO estimate is available to display.
has_cocomo: bool,
/// Pre-formatted COCOMO effort (person-months), e.g. "14.32".
cocomo_effort_str: String,
/// Pre-formatted COCOMO schedule (months), e.g. "6.18".
cocomo_duration_str: String,
/// Pre-formatted average team size, e.g. "2.32".
cocomo_staff_str: String,
/// Pre-formatted KSLOC input to COCOMO, e.g. "12.53".
cocomo_ksloc_str: String,
/// COCOMO mode label shown in the card (e.g. "Organic").
cocomo_mode_label: String,
/// Tooltip text explaining the selected COCOMO mode.
cocomo_mode_tooltip: String,
/// Per-file complexity alert threshold. 0 = off (no highlighting).
complexity_alert: u32,
}
#[derive(Template)]
#[template(
source = r##"
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OxideSLOC | Analyzing…</title>
<link rel="icon" type="image/png" href="/images/logo/small-logo.png">
<style nonce="{{ csp_nonce }}">
:root {
--radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
--line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
--nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
--oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
}
body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
*{box-sizing:border-box;} html,body{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);} body{display:flex;flex-direction:column;}
.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);}
.top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
.brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
.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));}
.brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
.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;}
.nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
@media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
@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; } }
.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;}
.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
.theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
.page-body{padding:32px 24px 36px;}
.wait-panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:36px 40px;box-shadow:var(--shadow);position:relative;}
.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;}
.pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:pulse 1.4s ease-in-out infinite;}
@keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}}
.wait-title{font-size:1.6rem;font-weight:800;color:var(--text);margin:0 0 6px;}
.wait-sub{color:var(--muted);font-size:0.95rem;margin-bottom:24px;}
.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;}
.metrics-row{display:flex;gap:20px;margin-bottom:24px;flex-wrap:wrap;}
.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;}
.metric-label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;}
.metric-value{font-size:1.1rem;font-weight:700;color:var(--text);}
.progress-bar-wrap{background:var(--surface-2);border-radius:999px;height:6px;overflow:hidden;margin-bottom:24px;}
.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;}
@keyframes indeterminate{0%{transform:translateX(-100%) scaleX(0.5);}50%{transform:translateX(0%) scaleX(0.5);}100%{transform:translateX(200%) scaleX(0.5);}}
.hidden{display:none!important;}
.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;}
.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;}
.err-panel strong{display:block;color:#8b1f1f;margin-bottom:6px;font-size:14px;}
.err-panel p{margin:0;font-size:13px;color:var(--muted);}
.actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:4px;}
.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);}
.btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(185,93,51,0.4);}
.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;}
.btn-outline:hover{background:rgba(185,93,51,0.08);transform:translateY(-1px);}
.background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
.background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
@keyframes wmFade{0%,100%{opacity:.07;}50%{opacity:.13;}}
.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;}
@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));}}
.site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
.site-footer a{color:var(--muted);}
.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;}
.theme-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
body:not(.dark-theme) .icon-moon{display:block;}body:not(.dark-theme) .icon-sun{display:none;}
body.dark-theme .icon-moon{display:none;}body.dark-theme .icon-sun{display:block;}
</style>
</head>
<body>
<div class="background-watermarks" aria-hidden="true">
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
</div>
<div class="code-particles" id="code-particles" aria-hidden="true"></div>
<nav class="top-nav">
<div class="top-nav-inner">
<a href="/" class="brand">
<img src="/images/logo/logo-text.png" alt="OxideSLOC" class="brand-logo">
<div class="brand-copy">
<h1 class="brand-title">OxideSLOC</h1>
<div class="brand-subtitle">local code analysis - metrics, history and reports</div>
</div>
</a>
<div class="nav-right">
<a class="nav-pill" href="/">Home</a>
<div class="nav-dropdown">
<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>
<div class="nav-dropdown-menu">
<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>
</div>
</div>
<a class="nav-pill" href="/compare-scans">Compare Scans</a>
<a class="nav-pill" href="/test-metrics">Test Metrics</a>
<div class="nav-dropdown">
<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>
<div class="nav-dropdown-menu">
<a href="/integrations"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
</div>
</div>
<div class="server-status-wrap" id="server-status-wrap">
<div class="nav-pill server-online-pill" id="server-status-pill">
<span class="status-dot" id="status-dot"></span>
<span id="server-status-label">Server</span>
<span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
</div>
<div class="server-status-tip">
OxideSLOC is running — accessible on your network.
<span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
</div>
</div>
<button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
<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>
</button>
<button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
<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>
<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>
</button>
</div>
</div>
</nav>
<div class="page-body">
<div class="wait-panel">
<div class="wait-badge"><span class="pulse-dot"></span>Analysis running</div>
<h2 class="wait-title">Analyzing your project…</h2>
<p class="wait-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
<div class="path-block">{{ project_path }}</div>
<div class="metrics-row">
<div class="metric-card">
<div class="metric-label">Elapsed</div>
<div class="metric-value" id="elapsed">0s</div>
</div>
<div class="metric-card">
<div class="metric-label">Phase</div>
<div class="metric-value" id="phase">Starting</div>
</div>
<div class="metric-card hidden" id="files-card">
<div class="metric-label">Files</div>
<div class="metric-value" id="files-progress">0</div>
</div>
</div>
<div class="progress-bar-wrap"><div class="progress-bar"></div></div>
<div class="warn-slow hidden" id="warn-slow">
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.
</div>
<div class="err-panel hidden" id="err-panel">
<strong>Analysis failed</strong>
<p id="err-msg">An unexpected error occurred. Check that the path exists and is readable.</p>
</div>
<div class="actions hidden" id="actions">
<a href="/scan" class="btn-primary">Try Again</a>
<a href="/view-reports" class="btn-outline">View Reports</a>
</div>
</div>
</div>
<script nonce="{{ csp_nonce }}">
(function() {
var WAIT_ID = {{ wait_id_json|safe }};
var startTime = Date.now();
var pollInterval = 1500;
var retries = 0;
var maxRetries = 5;
var warnShown = false;
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(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}
function elapsed() {
return Math.floor((Date.now() - startTime) / 1000);
}
function updateElapsed() {
var s = elapsed();
document.getElementById('elapsed').textContent = s < 60 ? s + 's' : Math.floor(s/60) + 'm ' + (s%60) + 's';
}
function setPhase(txt) {
document.getElementById('phase').textContent = txt;
}
var elapsedTimer = setInterval(updateElapsed, 1000);
function poll() {
fetch('/api/runs/' + encodeURIComponent(WAIT_ID) + '/status')
.then(function(r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
})
.then(function(data) {
retries = 0;
if (data.state === 'complete') {
clearInterval(elapsedTimer);
setPhase('Done');
window.location.href = '/runs/result/' + encodeURIComponent(data.run_id);
} else if (data.state === 'failed') {
clearInterval(elapsedTimer);
setPhase('Failed');
document.getElementById('err-msg').textContent = data.message || 'Analysis failed.';
document.getElementById('err-panel').classList.remove('hidden');
document.getElementById('actions').classList.remove('hidden');
} else {
// still running
var s = elapsed();
if (s > 90 && !warnShown) {
warnShown = true;
document.getElementById('warn-slow').classList.remove('hidden');
}
setPhase(data.phase || 'Running');
var fd = data.files_done || 0, ft = data.files_total || 0;
if (ft > 0) {
var card = document.getElementById('files-card');
if (card) card.classList.remove('hidden');
var fp = document.getElementById('files-progress');
if (fp) fp.textContent = fmt(fd) + ' / ' + fmt(ft);
}
setTimeout(poll, pollInterval);
}
})
.catch(function(err) {
retries++;
if (retries >= maxRetries) {
clearInterval(elapsedTimer);
document.getElementById('err-msg').textContent = 'Lost connection to server. Reload the page to check status.';
document.getElementById('err-panel').classList.remove('hidden');
document.getElementById('actions').classList.remove('hidden');
} else {
// exponential back-off capped at 8s
setTimeout(poll, Math.min(pollInterval * Math.pow(2, retries), 8000));
}
});
}
setTimeout(poll, pollInterval);
// If the browser restores this page from bfcache (Back after viewing results),
// timers may be frozen; kick off a fresh poll so we either redirect or resume.
window.addEventListener("pageshow", function(e) {
if (e.persisted) { setTimeout(poll, 200); }
});
})();
</script>
<footer class="site-footer">
local code analysis - metrics, history and reports
· <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
· Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
· <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
· <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
· <a href="/api-docs" rel="noopener">REST API</a>
</footer>
<script nonce="{{ csp_nonce }}">
(function(){
var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
if(s==="dark")b.classList.add("dark-theme");
var tt=document.getElementById("theme-toggle");
if(tt)tt.addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});
})();
(function spawnCodeParticles(){
var c=document.getElementById('code-particles');if(!c)return;
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'];
for(var i=0;i<32;i++){(function(idx){
var el=document.createElement('span');el.className='code-particle';el.textContent=sn[idx%sn.length];
var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1);
var dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1);
var 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);}
})();
(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);
var rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2);
var 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';
});
})();
</script>
<script nonce="{{ csp_nonce }}">
(function(){
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'}];
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);});}
try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
function init(){
var btn=document.getElementById('settings-btn');if(!btn)return;
var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
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>';
document.body.appendChild(m);
var g=document.getElementById('scheme-grid');
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);});
var cl=document.getElementById('settings-close');
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);
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');});
if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
}
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
}());
</script>
<script nonce="{{ csp_nonce }}">(function(){var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';
if(location.protocol==='file:'){if(lbl)lbl.textContent='Offline';if(dot){dot.style.background='#888';dot.style.boxShadow='none';}if(pingEl)pingEl.textContent='';if(fm)fm.textContent='oxide-sloc v{{ version }} \u2014 Saved Report';var td=document.querySelector('.server-status-tip');if(td)td.textContent='Saved HTML report \u2014 server not connected.';return;}
if(lbl)lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{{ version }} — Mode: '+(isServer?'Network Server':'Local');function setDot(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}function doPing(){var t0=performance.now();fetch('/healthz',{cache:'no-store'}).then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}).catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});}doPing();setInterval(doPing,5000);})();</script>
</body>
</html>
"##,
ext = "html"
)]
struct ScanWaitTemplate {
version: &'static str,
wait_id_json: String,
project_path: String,
csp_nonce: String,
}
#[derive(Template)]
#[template(
source = r##"
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OxideSLOC | Error</title>
<link rel="icon" type="image/png" href="/images/logo/small-logo.png">
<style nonce="{{ csp_nonce }}">
:root {
--radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
--line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
--nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
--oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
}
body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
*{box-sizing:border-box;} html,body{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);} body{display:flex;flex-direction:column;}
.background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
.background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
@keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
.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);}
.top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
.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));}
.brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
.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;}
.nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
@media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
@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; } }
.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;}
.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
.theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
.theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
.theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
.theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
.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;}
.settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
.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);}
.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;}
.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;}
.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;}
.scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
.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;}
.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);}
.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;}
.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;}
.tz-select:focus{border-color:var(--oxide);}
.page{width:100%;max-width:1720px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
@media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
.panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
.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;}
.actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
.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);}
.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;}
.btn-secondary:hover{background:var(--line);}
.bug-report-section{margin-top:28px;padding-top:22px;border-top:1px solid var(--line);}
.bug-report-trigger{display:inline-flex;align-items:center;gap:10px;padding:11px 22px;border-radius:14px;border:2px solid var(--oxide);background:transparent;color:var(--oxide);font-size:14px;font-weight:700;cursor:pointer;transition:background .18s ease,color .18s ease,box-shadow .18s ease;letter-spacing:.02em;}
.bug-report-trigger:hover,.bug-report-trigger:focus-visible{background:var(--oxide);color:#fff;box-shadow:0 4px 20px rgba(174,92,32,.28);outline:none;}
.bug-report-trigger .br-icon{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2;flex-shrink:0;}
.bug-report-trigger .br-chevron{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;transition:transform .2s ease;margin-left:2px;}
.bug-report-trigger.open .br-chevron{transform:rotate(180deg);}
.bug-report-panel{display:none;flex-direction:column;gap:12px;margin-top:18px;}
.bug-report-panel.open{display:flex;}
.br-network-badge{display:none;align-items:center;gap:6px;padding:4px 12px;border-radius:20px;font-size:11px;font-weight:700;width:fit-content;}
.br-network-badge.online{background:#e8f5ee;color:#2a6846;}
.br-network-badge.offline{background:#fff4e5;color:#9a5b00;}
body.dark-theme .br-network-badge.online{background:#1a3d2b;color:#5aba8a;}
body.dark-theme .br-network-badge.offline{background:#3d2a00;color:#f0a940;}
.br-net-dot{width:7px;height:7px;border-radius:50%;display:inline-block;flex-shrink:0;}
.br-network-badge.online .br-net-dot{background:#2a6846;}
.br-network-badge.offline .br-net-dot{background:#9a5b00;}
body.dark-theme .br-network-badge.online .br-net-dot{background:#5aba8a;}
body.dark-theme .br-network-badge.offline .br-net-dot{background:#f0a940;}
.bug-report-pre{background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:14px 16px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;line-height:1.65;color:var(--text);white-space:pre-wrap;overflow-wrap:anywhere;max-height:240px;overflow-y:auto;}
.bug-report-btns{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
.btn-sm{display:inline-flex;align-items:center;gap:6px;min-height:34px;padding:0 12px;border-radius:10px;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:12px;font-weight:700;cursor:pointer;text-decoration:none;transition:background .15s ease;}
.btn-sm:hover{background:var(--line);}
.btn-sm svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2;}
.bug-report-hint{font-size:11px;color:var(--muted);line-height:1.5;}
.bug-report-hint a{color:var(--oxide);text-decoration:none;font-weight:700;}
.bug-report-hint a:hover{text-decoration:underline;}
.site-footer{margin-top:auto;padding:16px 24px;text-align:center;font-size:11px;color:var(--muted);border-top:1px solid var(--line);position:relative;z-index:1;}
.site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
.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;}
.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;}
.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;}
@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));}}
.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;}
</style>
</head>
<body>
<div class="background-watermarks" aria-hidden="true">
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
</div>
<div class="code-particles" id="code-particles" aria-hidden="true"></div>
<div class="top-nav">
<div class="top-nav-inner">
<a class="brand" href="/">
<img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
<div class="brand-copy">
<div class="brand-title">OxideSLOC</div>
<div class="brand-subtitle">local code analysis - metrics, history and reports</div>
</div>
</a>
<div class="nav-right">
<a class="nav-pill" href="/">Home</a>
<div class="nav-dropdown">
<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>
<div class="nav-dropdown-menu">
<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>
</div>
</div>
<a class="nav-pill" href="/compare-scans">Compare Scans</a>
<a class="nav-pill" href="/test-metrics">Test Metrics</a>
<div class="nav-dropdown">
<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>
<div class="nav-dropdown-menu">
<a href="/integrations"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
</div>
</div>
<div class="server-status-wrap" id="server-status-wrap">
<div class="nav-pill server-online-pill" id="server-status-pill">
<span class="status-dot" id="status-dot"></span>
<span id="server-status-label">Server</span>
<span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
</div>
<div class="server-status-tip">
OxideSLOC is running — accessible on your network.
<span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
</div>
</div>
<button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
<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>
</button>
<button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
<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>
<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>
</button>
</div>
</div>
</div>
<div class="page">
<div class="panel">
<h1>Error</h1>
<div class="error-box" id="error-msg-text">{{ message }}</div>
<div id="br-meta" hidden
data-version="{{ version }}"
data-run-id="{% if let Some(rid) = run_id %}{{ rid }}{% endif %}"
data-error-code="{% if let Some(code) = error_code %}{{ code }}{% endif %}"></div>
<div class="actions">
<a class="btn-primary" href="/scan">Back to setup</a>
{% if let Some(report_url) = last_report_url %}
<a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
{% if report_url != "/view-reports" %}<a class="btn-secondary" href="/view-reports">View Reports</a>{% endif %}
{% else %}
<a class="btn-secondary" href="/view-reports">View Reports</a>
{% endif %}
</div>
<div class="bug-report-section" id="bug-report-section">
<button type="button" class="bug-report-trigger" id="bug-report-trigger" aria-expanded="false" aria-controls="bug-report-panel">
<svg class="br-icon" viewBox="0 0 24 24"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
Generate Bug Report
<svg class="br-chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<div class="bug-report-panel" id="bug-report-panel" role="region" aria-label="Bug report">
<div class="br-network-badge" id="br-network-badge"><span class="br-net-dot"></span><span id="br-network-label">Checking…</span></div>
<pre class="bug-report-pre" id="bug-report-pre">Collecting info…</pre>
<div class="bug-report-btns">
<button type="button" class="btn-sm" id="bug-report-copy">
<svg viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
Copy to clipboard
</button>
<a class="btn-sm" id="bug-report-github-link" href="https://github.com/oxide-sloc/oxide-sloc/issues/new" target="_blank" rel="noopener noreferrer" style="display:none;">
<svg viewBox="0 0 24 24"><path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 12 6.844a9.59 9.59 0 0 1 2.504.337c1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0 0 22 12.017C22 6.484 17.522 2 12 2z"/></svg>
Open GitHub Issue
</a>
<button type="button" class="btn-sm" id="bug-report-save" style="display:none;">
<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>
Save as file
</button>
</div>
<p class="bug-report-hint" id="br-hint-online" style="display:none;">Paste the report into a new GitHub issue, or click <strong>Open GitHub Issue</strong> to open a pre-filled draft. Remove any file paths you prefer not to share before posting.</p>
<p class="bug-report-hint" id="br-hint-offline" style="display:none;"><strong>Air-gapped system detected</strong> — GitHub is not reachable from this machine. Copy or save the report above, then open a <a href="https://github.com/oxide-sloc/oxide-sloc/issues/new" target="_blank" rel="noopener noreferrer">GitHub issue</a> from a connected machine and paste it there.</p>
</div>
</div>
</div>
</div>
<footer class="site-footer">
oxide-sloc v{{ version }} — local code metrics workbench ·
Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
· <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
· <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
· <a href="/api-docs" rel="noopener">REST API</a>
</footer>
<script nonce="{{ csp_nonce }}">(function(){
var meta=document.getElementById('br-meta');
var pre=document.getElementById('bug-report-pre');
var copyBtn=document.getElementById('bug-report-copy');
var trigger=document.getElementById('bug-report-trigger');
var panel=document.getElementById('bug-report-panel');
var networkBadge=document.getElementById('br-network-badge');
var networkLabel=document.getElementById('br-network-label');
var ghLink=document.getElementById('bug-report-github-link');
var saveBtn=document.getElementById('bug-report-save');
var hintOnline=document.getElementById('br-hint-online');
var hintOffline=document.getElementById('br-hint-offline');
if(!meta||!pre)return;
var ver=meta.getAttribute('data-version')||'';
var runId=meta.getAttribute('data-run-id')||'';
var code=meta.getAttribute('data-error-code')||'';
var msgEl=document.getElementById('error-msg-text');
var msg=msgEl?msgEl.textContent.trim():'';
function getBrowser(){
var ua=navigator.userAgent;
var m=ua.match(/(Edg|OPR|Chrome|Firefox|Safari)\/(\d+)/);
if(!m)return 'Unknown browser';
var n={'Edg':'Edge','OPR':'Opera'}[m[1]]||m[1];
return n+' '+m[2];
}
var lines=['oxide-sloc Bug Report','==============================',''];
lines.push('App version: v'+ver);
if(code)lines.push('HTTP status: '+code);
if(runId)lines.push('Run ID: '+runId);
lines.push('Page: '+window.location.pathname+(window.location.search||''));
lines.push('Timestamp: '+new Date().toISOString());
lines.push('Browser: '+getBrowser());
lines.push('Viewport: '+window.innerWidth+'x'+window.innerHeight);
lines.push('');
lines.push('Error message:');
lines.push(msg);
lines.push('');
lines.push('Steps to reproduce:');
lines.push(' 1. ');
lines.push('');
lines.push('Expected behavior:');
lines.push(' ');
pre.textContent=lines.join('\n');
function applyNetwork(online){
if(networkBadge){networkBadge.style.display='inline-flex';networkBadge.className='br-network-badge '+(online?'online':'offline');}
if(networkLabel)networkLabel.textContent=online?'Internet connected':'Air-gapped / offline';
if(ghLink){
if(online){
var body=encodeURIComponent(pre.textContent+'\n\n---\n*Generated by oxide-sloc v'+ver+'*');
ghLink.href='https://github.com/oxide-sloc/oxide-sloc/issues/new?title=Bug+Report&body='+body;
}
ghLink.style.display=online?'inline-flex':'none';
}
if(saveBtn)saveBtn.style.display=online?'none':'inline-flex';
if(hintOnline)hintOnline.style.display=online?'block':'none';
if(hintOffline)hintOffline.style.display=online?'none':'block';
}
applyNetwork(navigator.onLine);
var probed=false;
function probeNetwork(){
if(probed)return;probed=true;
var probeUrls=['https://github.com','https://www.google.com','https://www.cloudflare.com'];
var probeIdx=0;
function tryNext(){
if(probeIdx>=probeUrls.length){applyNetwork(false);return;}
var u=probeUrls[probeIdx++];
var c2=new AbortController();
var t2=setTimeout(function(){c2.abort();},4000);
fetch(u,{mode:'no-cors',cache:'no-store',signal:c2.signal})
.then(function(){clearTimeout(t2);applyNetwork(true);})
.catch(function(){clearTimeout(t2);tryNext();});
}
tryNext();
}
if(trigger&&panel){
trigger.addEventListener('click',function(){
var open=panel.classList.toggle('open');
trigger.classList.toggle('open',open);
trigger.setAttribute('aria-expanded',open?'true':'false');
if(open)probeNetwork();
});
}
if(copyBtn){
copyBtn.addEventListener('click',function(){
var txt=pre.textContent;
if(navigator.clipboard&&navigator.clipboard.writeText){
navigator.clipboard.writeText(txt).then(function(){
copyBtn.textContent='✓ Copied!';
setTimeout(function(){copyBtn.innerHTML='<svg viewBox="0 0 24 24" style="width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> Copy to clipboard';},2000);
});
}else{
var ta=document.createElement('textarea');
ta.value=txt;ta.style.position='fixed';ta.style.opacity='0';
document.body.appendChild(ta);ta.select();
try{document.execCommand('copy');copyBtn.textContent='✓ Copied!';}catch(e){}
document.body.removeChild(ta);
}
});
}
if(saveBtn){
saveBtn.addEventListener('click',function(){
var txt=pre.textContent;
var blob=new Blob([txt],{type:'text/plain'});
var url=URL.createObjectURL(blob);
var a=document.createElement('a');
a.href=url;a.download='oxide-sloc-bug-report-'+new Date().toISOString().slice(0,10)+'.txt';
document.body.appendChild(a);a.click();
document.body.removeChild(a);URL.revokeObjectURL(url);
});
}
})();</script>
<script nonce="{{ csp_nonce }}">
(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");});})();
(function spawnCodeParticles() {
var container = document.getElementById('code-particles');
if (!container) return;
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'];
for (var i = 0; i < 38; 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);
}
})();
(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(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]; }
var half = Math.floor(wms.length/2);
wms.forEach(function(img, i) {
var pos = pick(i < half);
var w = Math.floor(Math.random()*60+80);
var rot = (Math.random()*40-20).toFixed(1);
var op = (Math.random()*0.08+0.05).toFixed(2);
var animDur = (Math.random()*6+5).toFixed(1);
var animDelay = (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 '+animDur+'s ease-in-out -'+animDelay+'s infinite alternate';
});
})();
</script>
<script nonce="{{ csp_nonce }}">
(function(){
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'}];
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);});}
try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
function init(){
var btn=document.getElementById('settings-btn');if(!btn)return;
var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
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>';
document.body.appendChild(m);
var g=document.getElementById('scheme-grid');
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);});
var cl=document.getElementById('settings-close');
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);
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');});
if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
}
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
}());
</script>
<script nonce="{{ csp_nonce }}">(function(){var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';
if(location.protocol==='file:'){if(lbl)lbl.textContent='Offline';if(dot){dot.style.background='#888';dot.style.boxShadow='none';}if(pingEl)pingEl.textContent='';if(fm)fm.textContent='oxide-sloc v{{ version }} \u2014 Saved Report';var td=document.querySelector('.server-status-tip');if(td)td.textContent='Saved HTML report \u2014 server not connected.';return;}
if(lbl)lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{{ version }} — Mode: '+(isServer?'Network Server':'Local');function setDot(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}function doPing(){var t0=performance.now();fetch('/healthz',{cache:'no-store'}).then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}).catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});}doPing();setInterval(doPing,5000);})();</script>
</body>
</html>
"##,
ext = "html"
)]
struct ErrorTemplate {
message: String,
/// URL for the secondary action button (e.g. "/view-reports", "/compare-scans").
last_report_url: Option<String>,
/// Label for the secondary action button; defaults to "View last report" when None.
last_report_label: Option<String>,
/// Run ID to surface in the bug report; `None` when not applicable.
run_id: Option<String>,
/// HTTP status code to surface in the bug report; `None` when unknown.
error_code: Option<u16>,
csp_nonce: String,
version: &'static str,
}
// ── LocateFileTemplate ────────────────────────────────────────────────────────
#[derive(Template)]
#[template(
source = r##"
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OxideSLOC | Locate Report</title>
<link rel="icon" type="image/png" href="/images/logo/small-logo.png">
<style nonce="{{ csp_nonce }}">
:root{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;--line:#e6d0bf;--line-strong:#dcb89f;--text:#43342d;--muted:#7b675b;--muted-2:#a08878;--nav:#283790;--nav-2:#013e6b;--accent:#6f9bff;--accent-2:#4a78ee;--oxide:#d37a4c;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}
body.dark-theme{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;--line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;--muted-2:#9c877a;}
*{box-sizing:border-box;}html,body{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);}body{display:flex;flex-direction:column;}
.background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
.background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
.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);}
.top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
.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));}
.brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
.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;}
.nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
@media(max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
@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;}}
.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;}
.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
.theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
.theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
.theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
.theme-toggle .icon-sun{display:none;}body.dark-theme .theme-toggle .icon-sun{display:block;}body.dark-theme .theme-toggle .icon-moon{display:none;}
.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;}
.settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
.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);}
.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;}
.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;}
.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;}
.scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
.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;}
.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);}
.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;}
.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;}
.tz-select:focus{border-color:var(--oxide);}
.page{width:100%;max-width:1404px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
.panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
.panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 20px;line-height:1.55;}
.field-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:6px;}
.filename-chip{display:inline-flex;align-items:center;gap:8px;background:var(--surface-2);border:1px solid var(--line-strong);border-radius:8px;padding:9px 14px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;margin-bottom:22px;word-break:break-all;}
.filename-chip svg{flex:0 0 auto;opacity:0.6;}
.locate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
.locate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
.locate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
.locate-row{display:flex;gap:8px;align-items:stretch;}
.locate-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;}
.locate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
body.dark-theme .locate-input{background:var(--surface-2);}
.warning-banner{display:none;align-items:center;gap:8px;background:#fff4e5;border:1px solid #f5a623;border-radius:8px;padding:10px 14px;font-size:12px;color:#7a4f00;margin-top:8px;line-height:1.4;}
.warning-banner.show{display:flex;}
.warning-banner svg{flex:0 0 auto;}
body.dark-theme .warning-banner{background:#3d2800;border-color:#a06820;color:#ffcf7a;}
.error-inline{display:none;align-items:flex-start;gap:10px;background:#fde8e8;border:1px solid #e07070;border-radius:10px;padding:12px 16px;font-size:13px;color:#7a1e1e;margin-top:12px;line-height:1.55;}
.error-inline.show{display:flex;}
.error-inline svg{flex:0 0 auto;margin-top:2px;}
body.dark-theme .error-inline{background:#4a1e1e;border-color:#b85555;color:#ffb3b3;}
.err-kv{border-collapse:collapse;margin:6px 0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;}
.err-kv-k{padding:2px 14px 2px 0;font-weight:700;white-space:nowrap;vertical-align:top;opacity:.85;}
.err-kv-v{padding:2px 0;word-break:break-all;vertical-align:top;}
.err-kv-p{margin:0 0 4px;}
.success-inline{display:none;align-items:center;gap:10px;background:#e8faf0;border:1px solid #4caf80;border-radius:10px;padding:12px 16px;font-size:13px;color:#1a6b3c;margin-top:12px;}
.success-inline.show{display:flex;}
body.dark-theme .success-inline{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
.folder-hint-shell{border:1px solid var(--line);border-radius:14px;overflow:hidden;background:var(--surface);margin-top:20px;}
.folder-hint-hdr{padding:11px 16px;background:linear-gradient(180deg,var(--surface-2),rgba(255,255,255,0.35));border-bottom:1px solid var(--line);display:flex;align-items:center;gap:8px;font-size:12px;font-weight:800;color:var(--muted-2);text-transform:uppercase;letter-spacing:.07em;}
body.dark-theme .folder-hint-hdr{background:linear-gradient(180deg,var(--surface-2),rgba(0,0,0,0.12));}
.folder-hint-body{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;}
.fh-row{display:flex;align-items:center;gap:6px;padding:7px 14px;border-bottom:1px solid rgba(0,0,0,0.04);}
.fh-row:nth-child(odd){background:rgba(255,255,255,0.25);}
body.dark-theme .fh-row:nth-child(odd){background:rgba(255,255,255,0.02);}
.fh-row:last-child{border-bottom:none;}
.fh-i1{padding-left:36px;}.fh-i2{padding-left:58px;}
.fh-dir{font-weight:800;color:var(--text);}
.fh-hl{color:var(--oxide);font-weight:700;}
.fh-muted{color:var(--muted);}
.fh-badge{margin-left:auto;font-size:11px;font-weight:700;color:var(--oxide);background:rgba(184,93,51,0.10);border:1px solid rgba(184,93,51,0.25);border-radius:6px;padding:2px 8px;white-space:nowrap;}
body.dark-theme .fh-badge{background:rgba(255,140,90,0.15);border-color:rgba(255,140,90,0.30);}
.fh-tog{color:var(--muted-2);font-size:13px;flex:0 0 14px;}
.fh-bul{color:var(--muted-2);font-size:8px;flex:0 0 14px;text-align:center;opacity:0.5;}
.btn-row{margin-top:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;}
.btn-primary{display:inline-flex;align-items:center;justify-content:center;min-height:42px;padding:0 22px;border-radius:14px;border: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;}
.btn-primary:disabled{opacity:0.4;cursor:not-allowed;box-shadow:none;}
.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;}
.btn-secondary:hover{background:var(--line);}
.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;}
.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;}
.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;}
@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));}}
.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;}
.site-footer{margin-top:auto;padding:16px 24px;text-align:center;font-size:11px;color:var(--muted);border-top:1px solid var(--line);position:relative;z-index:1;}
.site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
</style>
</head>
<body>
<div class="background-watermarks" aria-hidden="true">
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
</div>
<div class="code-particles" id="code-particles" aria-hidden="true"></div>
<div class="top-nav">
<div class="top-nav-inner">
<a class="brand" href="/">
<img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
<div class="brand-copy">
<div class="brand-title">OxideSLOC</div>
<div class="brand-subtitle">local code analysis - metrics, history and reports</div>
</div>
</a>
<div class="nav-right">
<a class="nav-pill" href="/">Home</a>
<div class="nav-dropdown">
<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>
<div class="nav-dropdown-menu">
<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>
</div>
</div>
<a class="nav-pill" href="/compare-scans">Compare Scans</a>
<a class="nav-pill" href="/test-metrics">Test Metrics</a>
<div class="nav-dropdown">
<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>
<div class="nav-dropdown-menu">
<a href="/integrations"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
</div>
</div>
<div class="server-status-wrap" id="server-status-wrap">
<div class="nav-pill server-online-pill" id="server-status-pill">
<span class="status-dot" id="status-dot"></span>
<span id="server-status-label">Server</span>
<span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
</div>
<div class="server-status-tip">
OxideSLOC is running — accessible on your network.
<span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
</div>
</div>
<button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
<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>
</button>
<button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
<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>
<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>
</button>
</div>
</div>
</div>
<div class="page">
<div id="locate-meta" hidden data-expected="{{ expected_filename }}" data-run-id="{{ run_id }}" data-redirect="/runs/{{ artifact_type }}/{{ run_id }}"></div>
<div class="panel">
<h1>Report File Not Found</h1>
<p class="panel-subtitle">The report file could not be found — the output folder may have been moved or renamed. Select the <strong>top-level scan output folder</strong> to restore it.</p>
<div class="field-label">Missing file</div>
<div class="filename-chip">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>
{{ expected_filename }}
</div>
<div class="locate-section">
<h2>Locate Scan Output Folder</h2>
<p>Select the <strong>top-level scan output folder</strong> (the one named like <code>project_20260601-…</code> that contains the <code>html/</code>, <code>json/</code>, and <code>pdf/</code> subfolders).</p>
<p>OxideSLOC will find the correct files inside automatically.</p>
<div class="locate-row">
<input type="text" id="locate-file-input"
placeholder="e.g. C:\Desktop\over-here\project_20260601-0029-…"
class="locate-input" autocomplete="off" spellcheck="false">
{% if !server_mode %}
<button type="button" id="browse-locate-btn" class="btn-secondary">Browse…</button>
{% endif %}
</div>
<div class="warning-banner" id="filename-warning">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<span>Tip: select the <strong>folder</strong>, not an individual file. If you must pick a file directly, its name must match <strong>{{ expected_filename }}</strong>.</span>
</div>
<div class="error-inline" id="locate-error">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex:0 0 auto;margin-top:2px;"><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>
<span id="locate-error-text"></span>
</div>
<div class="success-inline" id="locate-success">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex:0 0 auto;"><polyline points="20 6 9 17 4 12"/></svg>
<span>Scan restored — loading report…</span>
</div>
<div class="btn-row">
<button type="button" id="locate-submit-btn" class="btn-primary" disabled>Restore Report</button>
<a class="btn-secondary" href="/view-reports">View Reports</a>
</div>
<div class="folder-hint-shell">
<div class="folder-hint-hdr">
<svg width="14" height="14" 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"/></svg>
Expected Folder Structure — Select the Top-Level Folder
</div>
<div class="folder-hint-body">
<div class="fh-row">
<span class="fh-tog">►</span>
<span class="fh-dir">project_20260601-0029-…/</span>
<span class="fh-badge">← select this</span>
</div>
<div class="fh-row fh-i1">
<span class="fh-tog">►</span>
<span class="fh-dir">html/</span>
</div>
<div class="fh-row fh-i2">
<span class="fh-bul">•</span>
<span class="fh-hl">{{ expected_filename }}</span>
</div>
<div class="fh-row fh-i1">
<span class="fh-tog">►</span>
<span class="fh-dir">json/</span>
</div>
<div class="fh-row fh-i2">
<span class="fh-bul">•</span>
<span class="fh-muted">result_*.json</span>
</div>
<div class="fh-row fh-i1">
<span class="fh-tog">►</span>
<span class="fh-dir">pdf/</span>
</div>
<div class="fh-row fh-i2">
<span class="fh-bul">•</span>
<span class="fh-muted">report_*.pdf</span>
</div>
<div class="fh-row fh-i1">
<span class="fh-tog">►</span>
<span class="fh-dir">excel/</span>
</div>
<div class="fh-row fh-i2">
<span class="fh-bul">•</span>
<span class="fh-muted">report_*.csv report_*.xlsx</span>
</div>
</div>
</div>
</div>
</div>
</div>
<footer class="site-footer">
oxide-sloc v{{ version }} — local code metrics workbench ·
Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
· <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
· <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
· <a href="/api-docs" rel="noopener">REST API</a>
</footer>
<script nonce="{{ csp_nonce }}">(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");
});
})();</script>
<script nonce="{{ csp_nonce }}">(function spawnCodeParticles(){
var c=document.getElementById('code-particles');if(!c)return;
var snips=['report moved','fn analyze()','locate file','.html report','restore path','folder path','result.json','run_id','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);}
})();
(function randomizeWatermarks(){var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));if(!wms.length)return;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()*100+120),rot=(Math.random()*360).toFixed(1),op=(Math.random()*0.08+0.12).toFixed(2);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;});})();</script>
<script nonce="{{ csp_nonce }}">(function(){
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'}];
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);});}
try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
function init(){var btn=document.getElementById('settings-btn');if(!btn)return;var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';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>';document.body.appendChild(m);var g=document.getElementById('scheme-grid');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);});var cl=document.getElementById('settings-close');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);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');});if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});}
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
}());</script>
<script nonce="{{ csp_nonce }}">(function(){
var meta=document.getElementById('locate-meta');
var inp=document.getElementById('locate-file-input');
var browseBtn=document.getElementById('browse-locate-btn');
var submitBtn=document.getElementById('locate-submit-btn');
var warning=document.getElementById('filename-warning');
var errBox=document.getElementById('locate-error');
var errText=document.getElementById('locate-error-text');
var okBox=document.getElementById('locate-success');
var expected=meta?meta.getAttribute('data-expected'):'';
var runId=meta?meta.getAttribute('data-run-id'):'';
var redirectUrl=meta?meta.getAttribute('data-redirect'):'/view-reports';
function basename(p){return p.replace(/\\/g,'/').split('/').pop()||'';}
function showErr(msg){
if(errText){
errText.innerHTML='';
var lines=msg.split('\n');
var hasPairs=lines.some(function(l){return / : /.test(l);});
if(!hasPairs){errText.textContent=msg;}
else{
var frag=document.createDocumentFragment();var tbl=null;
lines.forEach(function(line){
var m=line.match(/^(.*?) : (.*)$/);
if(m){
if(!tbl){tbl=document.createElement('table');tbl.className='err-kv';frag.appendChild(tbl);}
var tr=document.createElement('tr');
var k=document.createElement('td');k.className='err-kv-k';k.textContent=m[1].trim();
var v=document.createElement('td');v.className='err-kv-v';v.textContent=m[2];
tr.appendChild(k);tr.appendChild(v);tbl.appendChild(tr);
} else {
tbl=null;
if(line.trim()){var p=document.createElement('p');p.className='err-kv-p';p.textContent=line.trim();frag.appendChild(p);}
}
});
errText.appendChild(frag);
}
}
if(errBox)errBox.classList.add('show');
if(okBox)okBox.classList.remove('show');
}
function clearErr(){
if(errBox)errBox.classList.remove('show');
if(okBox)okBox.classList.remove('show');
}
function validate(){
var val=inp?inp.value.trim():'';
clearErr();
if(!val){if(submitBtn)submitBtn.disabled=true;if(warning)warning.classList.remove('show');return;}
if(submitBtn)submitBtn.disabled=false;
if(warning){
var name=basename(val);
var looksLikeFile=name.toLowerCase().slice(-5)==='.html';
if(expected&&name&&looksLikeFile&&name!==expected)warning.classList.add('show');
else warning.classList.remove('show');
}
}
if(inp){inp.addEventListener('input',validate);inp.addEventListener('keydown',function(e){if(e.key==='Enter')submitBtn&&submitBtn.click();});}
if(browseBtn){
browseBtn.addEventListener('click',function(){
browseBtn.disabled=true;browseBtn.textContent='...';
fetch('/pick-directory')
.then(function(r){return r.ok?r.json():{cancelled:true};})
.then(function(d){browseBtn.disabled=false;browseBtn.textContent='Browse…';if(d&&d.selected_path&&inp){inp.value=d.selected_path;validate();}})
.catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse…';});
});
}
if(submitBtn){
submitBtn.addEventListener('click',function(){
var folder=inp?inp.value.trim():'';
if(!folder){showErr('Please enter or browse to the scan output folder.');return;}
clearErr();
submitBtn.disabled=true;submitBtn.textContent='Restoring…';
var body=new URLSearchParams();
body.set('file_path',folder);
body.set('redirect_url',redirectUrl);
body.set('expected_run_id',runId);
fetch('/locate-report',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
.then(function(r){return r.json().catch(function(){return{ok:false,message:'Server returned an unexpected response (status '+r.status+').'}; });})
.then(function(d){
submitBtn.disabled=false;submitBtn.textContent='Restore Report';
if(d&&d.ok){
if(okBox)okBox.classList.add('show');
setTimeout(function(){window.location.href=d.redirect||redirectUrl;},500);
} else {
showErr(d&&d.message?d.message:'Unknown error. Check that the folder contains the correct scan.');
}
})
.catch(function(e){
submitBtn.disabled=false;submitBtn.textContent='Restore Report';
showErr('Network error: '+String(e));
});
});
}
})();</script>
<script nonce="{{ csp_nonce }}">(function(){var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';if(lbl)lbl.textContent=isServer?'Server':'Local';function setDot(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}function doPing(){var t0=performance.now();fetch('/healthz',{cache:'no-store'}).then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}).catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});}doPing();setInterval(doPing,5000);})();</script>
</body>
</html>
"##,
ext = "html"
)]
struct LocateFileTemplate {
run_id: String,
artifact_type: String,
expected_filename: String,
server_mode: bool,
csp_nonce: String,
version: &'static str,
}
// ── RelocateScanTemplate ──────────────────────────────────────────────────────
#[derive(Template)]
#[template(
source = r##"
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OxideSLOC | Locate Scan Files</title>
<link rel="icon" type="image/png" href="/images/logo/small-logo.png">
<style nonce="{{ csp_nonce }}">
:root {
--radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
--line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
--nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
--oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
}
body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
*{box-sizing:border-box;} html,body{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);} body{display:flex;flex-direction:column;}
.background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
.background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
@keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
.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);}
.top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
.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));}
.brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
.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;}
.nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
@media (max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
@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;}}
.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;}
.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
.theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
.theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
.theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
.theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
.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;}
.settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
.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);}
.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;}
.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;}
.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;}
.scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
.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;}
.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);}
.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;}
.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;}
.tz-select:focus{border-color:var(--oxide);}
.page{max-width:1200px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
.panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
.panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 18px;}
.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;}
.error-box.hidden{display:none;}
.success-box{border-radius:16px;border:1px solid #a3d9b5;background:#eafaf0;padding:16px 18px;font-size:13px;font-weight:600;color:#1a6b3c;margin-bottom:22px;display:none;}
body.dark-theme .success-box{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
.actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
.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;}
.site-footer{margin-top:auto;padding:18px 24px;text-align:center;font-size:12px;color:var(--muted);border-top:1px solid var(--line);background:transparent;}
.site-footer a{color:var(--oxide);text-decoration:none;}.site-footer a:hover{text-decoration:underline;}
.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;}
.btn-secondary:hover{background:var(--line);}
.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;}
.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;}
.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;}
@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));}}
.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;}
.relocate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
.relocate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
.relocate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
.relocate-row{display:flex;gap:8px;align-items:stretch;}
.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;}
.relocate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
body.dark-theme .relocate-input{background:var(--surface-2);}
</style>
</head>
<body>
<div class="background-watermarks" aria-hidden="true">
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
</div>
<div class="code-particles" id="code-particles" aria-hidden="true"></div>
<div class="top-nav">
<div class="top-nav-inner">
<a class="brand" href="/">
<img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
<div class="brand-copy">
<div class="brand-title">OxideSLOC</div>
<div class="brand-subtitle">local code analysis - metrics, history and reports</div>
</div>
</a>
<div class="nav-right">
<a class="nav-pill" href="/">Home</a>
<div class="nav-dropdown">
<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>
<div class="nav-dropdown-menu">
<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>
</div>
</div>
<a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
<a class="nav-pill" href="/test-metrics">Test Metrics</a>
<div class="nav-dropdown">
<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>
<div class="nav-dropdown-menu">
<a href="/integrations"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
</div>
</div>
<div class="server-status-wrap" id="server-status-wrap">
<div class="nav-pill server-online-pill" id="server-status-pill">
<span class="status-dot" id="status-dot"></span>
<span id="server-status-label">Server</span>
<span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
</div>
<div class="server-status-tip">
OxideSLOC is running — accessible on your network.
<span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
</div>
</div>
<button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
<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>
</button>
<button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
<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>
<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>
</button>
</div>
</div>
</div>
<div class="page">
<div class="panel">
<h1>Scan Files Moved</h1>
<p class="panel-subtitle">The scan output folder was moved, renamed, or deleted. Browse to its new location to restore the comparison.</p>
<div class="error-box" id="relocate-error-box">{{ message }}</div>
<div class="success-box" id="relocate-success-box">Scan restored — redirecting…</div>
<div class="relocate-section">
<h2>Locate Scan Output</h2>
<p>Select the folder that contains the scan output files (result_*.json, result_*.html, etc.).</p>
<div class="relocate-row">
<input type="text" id="relocate-folder" name="folder_path"
value="{{ folder_hint }}"
placeholder="Path to folder containing scan output..."
class="relocate-input" autocomplete="off" spellcheck="false">
{% if !server_mode %}
<button type="button" id="browse-relocate-btn" class="btn-secondary">Browse…</button>
{% endif %}
</div>
<div style="margin-top:12px;">
<button type="button" id="restore-btn" class="btn-primary" style="border:none;">Restore Scan</button>
</div>
</div>
<div class="actions">
<a class="btn-secondary" href="/compare-scans">Compare Scans</a>
<a class="btn-secondary" href="/view-reports">View Reports</a>
</div>
</div>
</div>
<footer class="site-footer">
oxide-sloc v{{ version }} — local code metrics workbench ·
Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
· <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
· <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
· <a href="/api-docs" rel="noopener">REST API</a>
</footer>
<script nonce="{{ csp_nonce }}">
(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");});})();
(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);}})();
(function randomizeWatermarks(){var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));if(!wms.length)return;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()*100+120),rot=(Math.random()*360).toFixed(1),op=(Math.random()*0.08+0.12).toFixed(2);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;});})();
</script>
<script nonce="{{ csp_nonce }}">
(function(){
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'}];
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);});}
try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
function init(){
var btn=document.getElementById('settings-btn');if(!btn)return;
var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
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>';
document.body.appendChild(m);
var g=document.getElementById('scheme-grid');
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);});
var cl=document.getElementById('settings-close');
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);
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');});
if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
}
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
}());
(function(){
var browseBtn=document.getElementById('browse-relocate-btn');
if(browseBtn){
browseBtn.addEventListener('click',function(){
browseBtn.disabled=true;browseBtn.textContent='...';
var inp=document.getElementById('relocate-folder');
var hint=inp?inp.value:'';
fetch('/pick-directory?kind=reports¤t='+encodeURIComponent(hint))
.then(function(r){return r.ok?r.json():{cancelled:true};})
.then(function(d){
browseBtn.disabled=false;browseBtn.textContent='Browse…';
if(d&&d.selected_path&&inp)inp.value=d.selected_path;
})
.catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse…';});
});
}
var restoreBtn=document.getElementById('restore-btn');
var errBox=document.getElementById('relocate-error-box');
var okBox=document.getElementById('relocate-success-box');
if(restoreBtn){
restoreBtn.addEventListener('click',function(){
var inp=document.getElementById('relocate-folder');
var folder=inp?inp.value.trim():'';
if(!folder){if(errBox){errBox.textContent='Please enter a folder path.';errBox.classList.remove('hidden');}return;}
restoreBtn.disabled=true;restoreBtn.textContent='Checking…';
var body=new URLSearchParams();
body.set('run_id','{{ run_id }}');
body.set('redirect_url','{{ redirect_url }}');
body.set('folder_path',folder);
fetch('/relocate-scan',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
.then(function(r){return r.json();})
.then(function(d){
restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
if(d&&d.ok){
if(errBox)errBox.classList.add('hidden');
if(okBox){okBox.style.display='block';}
setTimeout(function(){window.location.href=d.redirect||'/compare-scans';},600);
} else {
if(errBox){errBox.textContent=d&&d.message?d.message:'Unknown error.';errBox.classList.remove('hidden');}
}
})
.catch(function(e){
restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
if(errBox){errBox.textContent='Network error: '+String(e);errBox.classList.remove('hidden');}
});
});
}
}());
</script>
<script nonce="{{ csp_nonce }}">(function(){var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';
if(location.protocol==='file:'){if(lbl)lbl.textContent='Offline';if(dot){dot.style.background='#888';dot.style.boxShadow='none';}if(pingEl)pingEl.textContent='';if(fm)fm.textContent='oxide-sloc v{{ version }} \u2014 Saved Report';var td=document.querySelector('.server-status-tip');if(td)td.textContent='Saved HTML report \u2014 server not connected.';return;}
if(lbl)lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{{ version }} — Mode: '+(isServer?'Network Server':'Local');function setDot(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}function doPing(){var t0=performance.now();fetch('/healthz',{cache:'no-store'}).then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}).catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});}doPing();setInterval(doPing,5000);})();</script>
</body>
</html>
"##,
ext = "html"
)]
struct RelocateScanTemplate {
message: String,
run_id: String,
folder_hint: String,
redirect_url: String,
server_mode: bool,
csp_nonce: String,
version: &'static str,
}
// ── HistoryTemplate (View Reports) ────────────────────────────────────────────
#[derive(Template)]
#[template(
source = r##"
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OxideSLOC | View Reports</title>
<link rel="icon" type="image/png" href="/images/logo/small-logo.png">
<style nonce="{{ csp_nonce }}">
:root {
--radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
--line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
--nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
--oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
--pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6;
}
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; }
*{box-sizing:border-box;} html,body{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);} body{display:flex;flex-direction:column;}
.background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
.background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
.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);}
.top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
.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));}
.brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
.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;}
.nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
@media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
@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; } }
.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;}
.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
.theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
.theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
.theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
.theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
.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;}
.settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
.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);}
.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;}
.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;}
.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;}
.scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
.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;}
.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);}
.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;}
.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;}
.tz-select:focus{border-color:var(--oxide);}
.page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
@media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
.panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
.panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
.panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
.panel-meta{font-size:13px;color:var(--muted);}
.controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
.filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
.filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
.per-page-label{font-size:13px;color:var(--muted);}
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;}
.filter-input{min-width:180px;cursor:text;}
.table-wrap{width:100%;overflow-x:auto;}
table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
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;}
th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
.sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
.col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
.col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
tr:last-child td{border-bottom:none;}
tr:hover td{background:var(--surface-2);}
.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);}
.git-chip{font-family:ui-monospace,monospace;font-size:11px;font-weight:700;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);}
body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
.metric-num{font-weight:700;color:var(--text);}
.metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
.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;}
.btn:hover{background:var(--line);}
.btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
.btn.primary:hover{opacity:.9;}
.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;}
.btn-back:hover{background:var(--line);}
.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;}
.export-btn:hover{background:var(--line);}
.export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
.actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
.no-report{color:var(--muted);font-size:11px;font-style:italic;}
.empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
.empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
.pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
.pagination-info{font-size:13px;color:var(--muted);}
.pagination-btns{display:flex;gap:6px;}
.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;}
.pg-btn:hover:not(:disabled){background:var(--line);}
.pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
.pg-btn:disabled{opacity:.35;cursor:default;}
.summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
@media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
.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;}
.stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
.stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
.stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
.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);}
.stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
.stat-chip:hover .stat-chip-tip{opacity:1;}
.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;}
.site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
.site-footer a{color:var(--muted);}
@media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
.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%;}
.locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
.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;}
body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
.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;}
body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
.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;}
.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;}
.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;}
@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));}}
.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;}
.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;}
.toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
.toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
.watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
.watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
.watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
.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;}
.watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
.watched-chip-rm:hover{color:var(--oxide);}
.watched-none{font-size:11px;color:var(--muted);font-style:italic;}
.watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
.watched-bar-right .btn{box-sizing:border-box;height:28px;}
body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
.rpt-btn{min-width:58px;justify-content:center;}
.flex-row{display:flex;align-items:center;gap:8px;}
.report-cell{overflow:visible;white-space:normal;}
#history-table col:nth-child(1){width:185px;}
#history-table col:nth-child(2){width:220px;}
#history-table col:nth-child(3){width:100px;}
#history-table col:nth-child(4){width:72px;}
#history-table col:nth-child(5){width:82px;}
#history-table col:nth-child(6){width:82px;}
#history-table col:nth-child(7){width:65px;}
#history-table col:nth-child(8){width:90px;}
#history-table col:nth-child(9){width:85px;}
#history-table col:nth-child(10){width:115px;}
#history-table td:nth-child(2){white-space:normal;word-break:break-word;overflow:visible;}
.submod-details{margin-top:6px;font-size:12px;color:var(--muted);}
.submod-details summary{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}
.submod-details summary::-webkit-details-marker{display:none;}
.submod-link-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}
.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;}
.submod-view-btn:hover{background:rgba(111,155,255,0.22);}
body.dark-theme .submod-view-btn{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}
</style>
</head>
<body>
<div class="background-watermarks" aria-hidden="true">
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
</div>
<div class="code-particles" id="code-particles" aria-hidden="true"></div>
<div class="top-nav">
<div class="top-nav-inner">
<a class="brand" href="/">
<img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
<div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
</a>
<div class="nav-right">
<a class="nav-pill" href="/">Home</a>
<div class="nav-dropdown">
<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>
<div class="nav-dropdown-menu">
<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>
</div>
</div>
<a class="nav-pill" href="/compare-scans">Compare Scans</a>
<a class="nav-pill" href="/test-metrics">Test Metrics</a>
<div class="nav-dropdown">
<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>
<div class="nav-dropdown-menu">
<a href="/integrations"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
</div>
</div>
<div class="server-status-wrap" id="server-status-wrap">
<div class="nav-pill server-online-pill" id="server-status-pill">
<span class="status-dot" id="status-dot"></span>
<span id="server-status-label">Server</span>
<span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
</div>
<div class="server-status-tip">
OxideSLOC is running — accessible on your network.
<span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
</div>
</div>
<button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
<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>
</button>
<button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
<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>
<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>
</button>
</div>
</div>
</div>
<div class="page">
{% if let Some(err) = browse_error %}
<div class="toast-error">
<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>
{{ err }}
</div>
{% endif %}
{% if linked_count > 0 %}
<div class="toast-success">
<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>
{% if linked_count == 1 %}Report linked — it now appears{% else %}{{ linked_count }} reports linked — they now appear{% endif %} in the list below.
</div>
{% endif %}
<div class="watched-bar">
<div class="watched-bar-left">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>
<span class="watched-label">Watched Folders</span>
<div class="watched-chips">
{% if server_mode %}
<span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
{% else %}
{% for dir in watched_dirs %}
<span class="watched-chip">
<span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
<form method="POST" action="/watched-dirs/remove" style="display:contents">
<input type="hidden" name="folder_path" value="{{ dir }}">
<input type="hidden" name="redirect_to" value="/view-reports">
<button type="submit" class="watched-chip-rm" title="Remove folder">✕</button>
</form>
</span>
{% endfor %}
{% if watched_dirs.is_empty() %}
<span class="watched-none">No folders watched — click Choose to add one</span>
{% endif %}
{% endif %}
</div>
</div>
{% if !server_mode %}
<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="/view-reports">
<button type="submit" class="btn">↻ Refresh</button>
</form>
</div>
{% endif %}
</div>
{% if total_scans > 0 %}
<div class="summary-strip">
<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>
<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>
<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>
<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>
</div>
{% endif %}
<section class="panel">
<div class="panel-header">
<div>
<h1>View Reports</h1>
<p class="panel-meta">{{ total_scans }} report(s) available. Use the View or PDF button to open a report.</p>
{% if server_mode %}<p class="panel-meta" style="margin-top:4px;color:var(--muted);">Showing all scans from all users on this server — scan history is shared across authenticated sessions.</p>{% endif %}
</div>
<div class="flex-row">
<button type="button" class="export-btn" id="export-csv-btn">
<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>
Export CSV
</button>
<button type="button" class="export-btn" id="export-xls-btn">
<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>
Export Excel
</button>
</div>
</div>
{% if entries.is_empty() %}
<div class="empty-state">
<strong>No reports with viewable HTML yet</strong>
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.
</div>
{% else %}
<div class="filter-row">
<input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name…">
<select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
<button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
</div>
<div class="table-wrap">
<table id="history-table">
<colgroup>
<col><col><col><col><col><col><col><col><col><col>
</colgroup>
<thead>
<tr id="history-thead">
<th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
<th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
<th>Run ID<div class="col-resize-handle"></div></th>
<th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
<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>
<th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
<th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
<th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
<th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
<th>Report<div class="col-resize-handle"></div></th>
</tr>
</thead>
<tbody id="history-tbody">
{% for entry in entries %}
<tr class="history-row" data-run="{{ entry.run_id }}"
data-timestamp="{{ entry.timestamp }}"
data-project="{{ entry.project_label }}"
data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
data-skipped="{{ entry.files_skipped }}"
data-comments="{{ entry.comment_lines }}"
data-blank="{{ entry.blank_lines }}"
data-branch="{{ entry.git_branch }}"
data-commit="{{ entry.git_commit }}"
data-html-url="/runs/html/{{ entry.run_id }}">
<td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
<td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
<td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
<td><span class="metric-num">{{ entry.files_analyzed }}</span><div class="metric-secondary">{{ entry.files_skipped }} skipped</div></td>
<td><span class="metric-num">{{ entry.code_lines }}</span></td>
<td><span class="metric-num">{{ entry.comment_lines }}</span></td>
<td><span class="metric-num">{{ entry.blank_lines }}</span></td>
<td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">—</span>{% endif %}</td>
<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>
<td class="report-cell">
<div class="actions-cell">
{% 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 %}
{% 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 %}
</div>
{% if !entry.submodule_links.is_empty() %}
<details class="submod-details">
<summary>↳ {{ entry.submodule_links.len() }} submodule(s)</summary>
<div class="submod-link-list">
{% for sub in entry.submodule_links %}
<a href="{{ sub.url }}" target="_blank" rel="noopener" class="submod-view-btn">{{ sub.name }}</a>
{% endfor %}
</div>
</details>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="pagination">
<span class="pagination-info" id="pagination-info"></span>
<div class="pagination-btns" id="pagination-btns"></div>
<div class="flex-row">
<span class="per-page-label">Show</span>
<select class="per-page" id="per-page-sel">
<option value="10">10 per page</option>
<option value="25" selected>25 per page</option>
<option value="50">50 per page</option>
<option value="100">100 per page</option>
</select>
<span class="per-page-label" id="page-range-label"></span>
</div>
</div>
{% endif %}
</section>
</div>
<footer class="site-footer">
local code analysis - metrics, history and reports
· <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
· Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
· <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
· <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
· <a href="/api-docs" rel="noopener">REST API</a>
</footer>
<script nonce="{{ csp_nonce }}">
(function () {
// ── Theme ──────────────────────────────────────────────────────────────
var storageKey = 'oxide-sloc-theme';
var body = document.body;
try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
var toggle = document.getElementById('theme-toggle');
if (toggle) toggle.addEventListener('click', function () {
var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
body.classList.toggle('dark-theme', next === 'dark');
try { localStorage.setItem(storageKey, next); } catch(e) {}
});
// ── State ─────────────────────────────────────────────────────────────
var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
// Aggregate stats from first (most recent) row
if (allRows.length) {
var first = allRows[0];
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(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}
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>':'');}
setChipVal('agg-code', first.dataset.code);
setChipVal('agg-files', first.dataset.files);
var projects = {}; allRows.forEach(function(r){var p=r.dataset.project||'';if(p)projects[p]=true;});
var pe=document.getElementById('agg-projects'); if(pe) pe.textContent=Object.keys(projects).filter(Boolean).length;
}
// ── Branch filter population ──────────────────────────────────────────
(function() {
var branches = {};
allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
var sel = document.getElementById('branch-filter');
if (sel) Object.keys(branches).sort().forEach(function(b) {
var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
});
})();
// ── Filter ────────────────────────────────────────────────────────────
function getFilteredRows() {
var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
var branch = ((document.getElementById('branch-filter') || {}).value || '');
return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
if (branch && (r.dataset.branch || '') !== branch) return false;
return true;
});
}
// ── Pagination ────────────────────────────────────────────────────────
function renderPage() {
var filtered = getFilteredRows();
var total = filtered.length;
var totalPages = Math.max(1, Math.ceil(total / perPage));
currentPage = Math.min(currentPage, totalPages);
var start = (currentPage - 1) * perPage;
var end = Math.min(start + perPage, total);
var shown = {};
filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
r.style.display = shown[r.dataset.run] ? '' : 'none';
});
var rl = document.getElementById('page-range-label');
if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
var info = document.getElementById('pagination-info');
if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
var btns = document.getElementById('pagination-btns');
if (!btns) return;
btns.innerHTML = '';
function makeBtn(lbl, pg, active, disabled) {
var b = document.createElement('button');
b.className = 'pg-btn' + (active ? ' active' : '');
b.textContent = lbl; b.disabled = disabled;
if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
return b;
}
btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
}
window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
window.applyFilters = function() { currentPage = 1; renderPage(); };
// ── Sorting ───────────────────────────────────────────────────────────
var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
function doSort(col, type, order) {
var tbody = document.getElementById('history-tbody');
if (!tbody) return;
var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
rows.sort(function(a, b) {
var va = a.dataset[col] || '', vb = b.dataset[col] || '';
if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
return va < vb ? 1 : va > vb ? -1 : 0;
});
rows.forEach(function(r) { tbody.appendChild(r); });
currentPage = 1; renderPage();
}
sortHeaders.forEach(function(th) {
th.addEventListener('click', function(e) {
if (e.target.classList.contains('col-resize-handle')) return;
var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
th.classList.add('sort-' + sortOrder);
var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
doSort(col, type, sortOrder);
});
});
// ── Column resize ─────────────────────────────────────────────────────
(function() {
var table = document.getElementById('history-table');
if (!table) return;
var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
ths.forEach(function(th, i) {
var handle = th.querySelector('.col-resize-handle');
if (!handle || !cols[i]) return;
var startX, startW;
handle.addEventListener('mousedown', function(e) {
e.stopPropagation(); e.preventDefault();
startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
handle.classList.add('dragging');
function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
});
})();
// ── Reset view ────────────────────────────────────────────────────────
window.resetView = function() {
var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
sortCol = null; sortOrder = 'asc';
sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
var tbody = document.getElementById('history-tbody');
if (tbody) {
var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
rows.forEach(function(r) { tbody.appendChild(r); });
}
var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
var table = document.getElementById('history-table');
if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
currentPage = 1; renderPage();
};
renderPage();
// ── Export helpers ────────────────────────────────────────────────────
function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
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);}
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;');}
function slocXlsx(fname,sheet,hdrs,rows){
var enc=new TextEncoder();
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;}
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;}
function u2(n){return[n&0xFF,(n>>8)&0xFF];}
function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
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;}
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];}
var rx='<row r="1">';
hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
rx+='</row>';
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>';});
var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
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>';
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>';
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>';
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>',
'_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>',
'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>',
'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>',
'xl/styles.xml':stl,'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh};
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'];
var zparts=[],zcds=[],zoff=0,znf=0;
order.forEach(function(name){
var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
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]);
var entry=new Uint8Array(lha.length+nb.length+sz);
entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
zparts.push(entry);
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));
var cde=new Uint8Array(cda.length+nb.length);
cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
zcds.push(cde);zoff+=entry.length;znf++;
});
var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
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]);
var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
zout.set(new Uint8Array(ea),zpos);
slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
}
var _hh = ['Timestamp','Project','Run ID','Files Analyzed','Files Skipped','Code Lines','Comments','Blank','Branch','Commit'];
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;}
window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
window.exportHistoryXls = function(){slocXlsx('scan-history.xlsx','Scan History',_hh,getHistoryRows());};
var csvBtn = document.getElementById('export-csv-btn');
if (csvBtn) csvBtn.addEventListener('click', function() { window.exportHistoryCsv(); });
var xlsBtn = document.getElementById('export-xls-btn');
if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportHistoryXls(); });
// ── Remaining CSP-safe event bindings ────────────────────────────────
(function wireEvents() {
var el;
el = document.getElementById('reset-view-btn');
if (el) el.addEventListener('click', window.resetView);
el = document.getElementById('project-filter');
if (el) el.addEventListener('input', window.applyFilters);
el = document.getElementById('branch-filter');
if (el) el.addEventListener('change', window.applyFilters);
el = document.getElementById('per-page-sel');
if (el) el.addEventListener('change', function() { window.setPerPage(this.value); });
el = document.getElementById('add-watched-btn');
if (el) el.addEventListener('click', function() {
fetch('/pick-directory?kind=reports')
.then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
.then(function(data) {
if (!data.cancelled && data.selected_path) {
var form = document.createElement('form');
form.method = 'POST';
form.action = '/watched-dirs/add';
var ri = document.createElement('input');
ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
var fi = document.createElement('input');
fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
form.appendChild(ri); form.appendChild(fi);
document.body.appendChild(form);
form.submit();
}
})
.catch(function(e) { alert('Could not open folder picker: ' + e); });
});
})();
(function randomizeWatermarks() {
var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
if (!wms.length) return;
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),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;});
})();
(function spawnCodeParticles() {
var container = document.getElementById('code-particles');
if (!container) return;
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'];
for (var i = 0; i < 38; 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);
}
})();
})();
</script>
<script nonce="{{ csp_nonce }}">
(function(){
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'}];
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);});}
try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
function init(){
var btn=document.getElementById('settings-btn');if(!btn)return;
var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
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>';
document.body.appendChild(m);
var g=document.getElementById('scheme-grid');
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);});
var cl=document.getElementById('settings-close');
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);
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');});
if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
}
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
}());
</script>
<script nonce="{{ csp_nonce }}">(function(){var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';if(lbl&&lbl.textContent==='Server')lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{{ version }} — Mode: '+(isServer?'Network Server':'Local');function setDot(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}function doPing(){var t0=performance.now();fetch('/healthz',{cache:'no-store'}).then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}).catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});}doPing();setInterval(doPing,5000);})();</script>
</body>
</html>
"##,
ext = "html"
)]
struct HistoryTemplate {
version: &'static str,
entries: Vec<HistoryEntryRow>,
total_scans: usize,
linked_count: usize,
browse_error: Option<String>,
watched_dirs: Vec<String>,
csp_nonce: String,
server_mode: bool,
}
// ── CompareSelectTemplate ──────────────────────────────────────────────────────
#[derive(Template)]
#[template(
source = r##"
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OxideSLOC | Compare Scans</title>
<link rel="icon" type="image/png" href="/images/logo/small-logo.png">
<style nonce="{{ csp_nonce }}">
:root {
--radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
--line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
--nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
--oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
--sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
}
body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
*{box-sizing:border-box;} html,body{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);} body{display:flex;flex-direction:column;}
.background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
.background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
.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);}
.top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
.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));}
.brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
.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;}
.nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
@media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
@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; } }
.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;}
.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
.theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
.theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
.theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
.theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
.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;}
.settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
.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);}
.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;}
.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;}
.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;}
.scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
.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;}
.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);}
.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;}
.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;}
.tz-select:focus{border-color:var(--oxide);}
.page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
@media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
.panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
.panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
.panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
.panel-meta{font-size:13px;color:var(--muted);margin:0;}
.compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
.controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
.filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
.filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
.per-page-label{font-size:13px;color:var(--muted);}
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;}
.filter-input{min-width:180px;cursor:text;}
.table-wrap{width:100%;overflow-x:auto;}
table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
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;}
th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
.sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
#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;}
#compare-table th:nth-child(2),#compare-table td:nth-child(2){min-width:185px;}
#compare-table th:nth-child(3),#compare-table td:nth-child(3){min-width:300px;}
#compare-table th:nth-child(4),#compare-table td:nth-child(4){min-width:78px;}
#compare-table th:nth-child(5),#compare-table td:nth-child(5){min-width:55px;}
#compare-table th:nth-child(6),#compare-table td:nth-child(6){min-width:75px;}
#compare-table th:nth-child(7),#compare-table td:nth-child(7){min-width:65px;}
#compare-table th:nth-child(8),#compare-table td:nth-child(8){min-width:50px;}
#compare-table th:nth-child(9),#compare-table td:nth-child(9){min-width:75px;}
#compare-table th:nth-child(10),#compare-table td:nth-child(10){min-width:75px;}
th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
.col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
.col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
tr:last-child td{border-bottom:none;}
tr.selected td{background:var(--sel-bg);}
tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
tr:hover:not(.selected):not(.row-locked) td{background:var(--surface-2);}
tr{cursor:pointer;}
tr.row-locked{opacity:.35;cursor:not-allowed;}
tr.row-locked td{pointer-events:none;}
.compare-all-bar{display:flex;flex-wrap:wrap;gap:8px;padding:10px 14px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;margin:10px 0 14px;align-items:center;}
.compare-all-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);flex-shrink:0;}
.compare-all-btn{display:inline-flex;align-items:center;gap:6px;padding:5px 12px;border-radius:7px;border:1px solid var(--accent-2);background:rgba(111,155,255,0.08);color:var(--accent-2);font-size:12px;font-weight:700;cursor:pointer;transition:background .12s;}
.compare-all-btn:hover{background:rgba(111,155,255,0.18);}
body.dark-theme .compare-all-btn{background:rgba(111,155,255,0.12);color:var(--accent);border-color:var(--accent);}
body.dark-theme .compare-all-btn:hover{background:rgba(111,155,255,0.22);}
.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);}
.git-chip{font-family:ui-monospace,monospace;font-size:11px;font-weight:700;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);}
body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
.metric-num{font-weight:700;color:var(--text);}
.metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
.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;}
tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
.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;}
.btn:hover{background:var(--line);}
.btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
.btn.primary:hover{opacity:.9;}
.btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
.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;}
.toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
.toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
.watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
.watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
.watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
.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;}
.watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
.watched-chip-rm:hover{color:var(--oxide);}
.watched-none{font-size:11px;color:var(--muted);font-style:italic;}
.watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
.watched-bar-right .btn{box-sizing:border-box;height:28px;}
body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
.submod-chips-cell{display:flex;flex-wrap:wrap;gap:2px;align-items:flex-start;max-height:50px;overflow:hidden;}
.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;}
.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;}
.btn-back:hover{background:var(--line);}
.empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
.empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
.pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
.pagination-info{font-size:13px;color:var(--muted);}
.pagination-btns{display:flex;gap:6px;}
.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;}
.pg-btn:hover:not(:disabled){background:var(--line);}
.pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
.pg-btn:disabled{opacity:.35;cursor:default;}
.hint-right-wrap .instruction-bar{max-width:fit-content!important;width:auto!important;}
.site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
.site-footer a{color:var(--muted);}
@media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
.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;}
.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;}
.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;}
@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));}}
.summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
@media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
.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;}
.stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
.stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
.stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
.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);}
.stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
.stat-chip:hover .stat-chip-tip{opacity:1;}
.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;}
.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;}
.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%;}
body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
.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;}
body.dark-theme .submod-chip{background:rgba(111,155,255,0.16);border-color:rgba(111,155,255,0.32);color:var(--accent);}
#compare-table td:nth-child(11){white-space:normal;overflow:visible;}
.hidden{display:none!important;}
.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%;}
@keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
body.dark-theme .scope-panel{background:rgba(111,155,255,0.09);border-color:rgba(111,155,255,0.32);}
.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;}
.scope-panel-label svg{stroke:currentColor;fill:none;stroke-width:2;}
.scope-options{display:flex;flex-wrap:wrap;gap:8px;}
.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;}
.scope-option:hover{background:var(--line);}
.scope-option.selected{border-color:var(--accent-2);background:rgba(111,155,255,0.12);color:var(--accent-2);}
body.dark-theme .scope-option.selected{background:rgba(111,155,255,0.18);color:var(--accent);}
.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;}
.scope-option.selected .scope-option-radio{border-color:var(--accent-2);}
.scope-option.selected .scope-option-radio::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--accent-2);}
.scope-option-sep{width:1px;height:16px;background:rgba(111,155,255,0.28);margin:0 2px;flex-shrink:0;}
.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;}
</style>
</head>
<body>
<div class="background-watermarks" aria-hidden="true">
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
</div>
<div class="code-particles" id="code-particles" aria-hidden="true"></div>
<div class="top-nav">
<div class="top-nav-inner">
<a class="brand" href="/">
<img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
<div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
</a>
<div class="nav-right">
<a class="nav-pill" href="/">Home</a>
<div class="nav-dropdown">
<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>
<div class="nav-dropdown-menu">
<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>
</div>
</div>
<a class="nav-pill" href="/compare-scans">Compare Scans</a>
<a class="nav-pill" href="/test-metrics">Test Metrics</a>
<div class="nav-dropdown">
<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>
<div class="nav-dropdown-menu">
<a href="/integrations"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
</div>
</div>
<div class="server-status-wrap" id="server-status-wrap">
<div class="nav-pill server-online-pill" id="server-status-pill">
<span class="status-dot" id="status-dot"></span>
<span id="server-status-label">Server</span>
<span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
</div>
<div class="server-status-tip">
OxideSLOC is running — accessible on your network.
<span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
</div>
</div>
<button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
<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>
</button>
<button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
<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>
<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>
</button>
</div>
</div>
</div>
<div class="page">
<div class="watched-bar">
<div class="watched-bar-left">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>
<span class="watched-label">Watched Folders</span>
<div class="watched-chips">
{% if server_mode %}
<span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
{% else %}
{% for dir in watched_dirs %}
<span class="watched-chip">
<span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
<form method="POST" action="/watched-dirs/remove" style="display:contents">
<input type="hidden" name="folder_path" value="{{ dir }}">
<input type="hidden" name="redirect_to" value="/compare-scans">
<button type="submit" class="watched-chip-rm" title="Remove folder">✕</button>
</form>
</span>
{% endfor %}
{% if watched_dirs.is_empty() %}
<span class="watched-none">No folders watched — click Choose to add one</span>
{% endif %}
{% endif %}
</div>
</div>
{% if !server_mode %}
<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="/compare-scans">
<button type="submit" class="btn">↻ Refresh</button>
</form>
</div>
{% endif %}
</div>
{% if total_scans > 0 %}
<div class="summary-strip">
<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>
<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>
<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>
<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>
</div>
{% endif %}
<section class="panel">
<div class="panel-header">
<div>
<h1>Compare Scans</h1>
<p class="panel-meta">{{ total_scans }} scan record(s) available. Select two or more scans from the same project, then press Compare.</p>
</div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;">
<button class="btn primary" id="compare-btn" disabled>
<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>
Compare <span class="sel-count" id="sel-count">0</span> Selected
</button>
</div>
</div>
</div>
{% if entries.is_empty() %}
<div class="empty-state">
<strong>No scans yet</strong>
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.
</div>
{% else %}
<div class="filter-row">
<input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name…">
<select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
<button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
</div>
<div class="scope-panel hidden" id="scope-panel">
<div class="scope-panel-label">
<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>
Compare scope — choose what to include
</div>
<div class="scope-options" id="scope-options"></div>
</div>
{% if total_scans > 0 %}
<div class="hint-right-wrap" style="display:flex;justify-content:flex-end;margin:6px 0 8px;">
<div class="instruction-bar" style="margin:0;max-width:fit-content;flex-shrink:0;">
<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>
Select rows from the <strong>same project</strong>, then press <strong>Compare</strong> — or use <strong>Compare All</strong> for a full project history.
</div>
</div>
{% endif %}
<div id="compare-all-bar" class="compare-all-bar" style="display:none">
<span class="compare-all-label">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline></svg>
Quick Compare All
</span>
</div>
<div class="table-wrap">
<table id="compare-table">
<colgroup><col><col><col><col><col><col><col><col><col><col><col></colgroup>
<thead>
<tr id="compare-thead">
<th><div class="col-resize-handle"></div></th>
<th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
<th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
<th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
<th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
<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>
<th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
<th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
<th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
<th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
<th>Submodules<div class="col-resize-handle"></div></th>
</tr>
</thead>
<tbody id="compare-tbody">
{% for entry in entries %}
<tr class="compare-row" data-run="{{ entry.run_id }}" data-vid="{{ entry.run_id }}"
data-timestamp="{{ entry.timestamp }}" data-sort-ts="{{ entry.timestamp_utc_ms }}"
data-project="{{ entry.project_label }}"
data-files="{{ entry.files_analyzed }}"
data-code="{{ entry.code_lines }}"
data-comments="{{ entry.comment_lines }}"
data-blank="{{ entry.blank_lines }}"
data-branch="{{ entry.git_branch }}"
data-commit="{{ entry.git_commit }}"
data-submodules="{{ entry.submodule_names_csv }}">
<td><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
<td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
<td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
<td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
<td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
<td><span class="metric-num">{{ entry.code_lines }}</span></td>
<td><span class="metric-num">{{ entry.comment_lines }}</span></td>
<td><span class="metric-num">{{ entry.blank_lines }}</span></td>
<td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
<td>{% if !entry.git_commit.is_empty() %}<span class="git-chip">{{ entry.git_commit }}</span>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
<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>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="pagination">
<span class="pagination-info" id="pagination-info"></span>
<div class="pagination-btns" id="pagination-btns"></div>
<div class="flex-row">
<span class="per-page-label">Show</span>
<select class="per-page" id="per-page-sel">
<option value="10">10 per page</option>
<option value="25" selected>25 per page</option>
<option value="50">50 per page</option>
<option value="100">100 per page</option>
</select>
<span class="per-page-label" id="page-range-label"></span>
</div>
</div>
{% endif %}
</section>
</div>
<footer class="site-footer">
local code analysis - metrics, history and reports
· <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
· Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
· <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
· <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
· <a href="/api-docs" rel="noopener">REST API</a>
</footer>
<script nonce="{{ csp_nonce }}">
(function () {
// ── Theme ──────────────────────────────────────────────────────────────
var storageKey = 'oxide-sloc-theme';
var body = document.body;
try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
var toggle = document.getElementById('theme-toggle');
if (toggle) toggle.addEventListener('click', function () {
var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
body.classList.toggle('dark-theme', next === 'dark');
try { localStorage.setItem(storageKey, next); } catch(e) {}
});
// ── State ─────────────────────────────────────────────────────────────
var perPage = 25, currentPage = 1, sortCol = 'timestamp', sortOrder = 'desc';
var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
window._allCompareRows = allRows;
// ── Stat chips ────────────────────────────────────────────────────────
(function() {
var projects = {}, latestTs = '', latestRow = null;
allRows.forEach(function(r) {
var p = r.dataset.project || ''; if (p) projects[p] = true;
var ts = r.dataset.timestamp || '';
if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
});
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(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}
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>':'');}
var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
if (latestRow) {
setChipVal('agg-code', latestRow.dataset.code);
setChipVal('agg-files', latestRow.dataset.files);
}
})();
// ── Branch filter population ──────────────────────────────────────────
(function() {
var branches = {};
allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
var sel = document.getElementById('branch-filter');
if (sel) Object.keys(branches).sort().forEach(function(b) {
var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
});
})();
// ── Filter ────────────────────────────────────────────────────────────
function getFilteredRows() {
var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
var branch = ((document.getElementById('branch-filter') || {}).value || '');
return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
if (branch && (r.dataset.branch || '') !== branch) return false;
return true;
});
}
// ── Pagination ────────────────────────────────────────────────────────
function renderPage() {
var filtered = getFilteredRows();
var total = filtered.length;
var totalPages = Math.max(1, Math.ceil(total / perPage));
currentPage = Math.min(currentPage, totalPages);
var start = (currentPage - 1) * perPage;
var end = Math.min(start + perPage, total);
var shown = {};
filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
r.style.display = shown[r.dataset.run] ? '' : 'none';
});
var rl = document.getElementById('page-range-label');
if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total : 'No results';
var info = document.getElementById('pagination-info');
if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
var btns = document.getElementById('pagination-btns');
if (!btns) return;
btns.innerHTML = '';
function makeBtn(lbl, pg, active, disabled) {
var b = document.createElement('button');
b.className = 'pg-btn' + (active ? ' active' : '');
b.textContent = lbl; b.disabled = disabled;
if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
return b;
}
btns.appendChild(makeBtn('‹', currentPage - 1, false, currentPage === 1));
var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
btns.appendChild(makeBtn('›', currentPage + 1, false, currentPage === totalPages));
}
window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
window.applyFilters = function() { currentPage = 1; renderPage(); };
// ── Sorting ───────────────────────────────────────────────────────────
var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
function doSort(col, type, order) {
var tbody = document.getElementById('compare-tbody');
if (!tbody) return;
var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
rows.sort(function(a, b) {
var va = a.dataset[col] || '', vb = b.dataset[col] || '';
if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
return va < vb ? 1 : va > vb ? -1 : 0;
});
rows.forEach(function(r) { tbody.appendChild(r); });
currentPage = 1; renderPage();
}
sortHeaders.forEach(function(th) {
th.addEventListener('click', function(e) {
if (e.target.classList.contains('col-resize-handle')) return;
var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
th.classList.add('sort-' + sortOrder);
var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
doSort(col, type, sortOrder);
});
});
// Apply default sort (timestamp desc) on initial load
(function() {
var tsTh = document.querySelector('#compare-thead [data-sort-col="timestamp"]');
if (tsTh) { tsTh.classList.add('sort-desc'); var si = tsTh.querySelector('.sort-icon'); if (si) si.textContent = '↓'; doSort('timestamp', 'str', 'desc'); }
})();
// ── Column resize ─────────────────────────────────────────────────────
(function() {
var table = document.getElementById('compare-table');
if (!table) return;
var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
ths.forEach(function(th, i) {
var handle = th.querySelector('.col-resize-handle');
if (!handle || !cols[i]) return;
var startX, startW;
handle.addEventListener('mousedown', function(e) {
e.stopPropagation(); e.preventDefault();
startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
handle.classList.add('dragging');
function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
});
})();
// ── Reset view ────────────────────────────────────────────────────────
window.resetView = function() {
var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
sortCol = null; sortOrder = 'asc';
sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
var tbody = document.getElementById('compare-tbody');
if (tbody) {
var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
rows.forEach(function(r) { tbody.appendChild(r); });
}
var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
var table = document.getElementById('compare-table');
currentPage = 1; renderPage();
currentPage = 1; renderPage();
};
renderPage();
buildCompareAllBar();
// ── Row selection state ───────────────────────────────────────────────
var selected = [];
var lockedProject = null; // project label of first selected scan
function updateCompareBtn() {
var btn = document.getElementById('compare-btn');
var cnt = document.getElementById('sel-count');
if (!btn) return;
btn.disabled = selected.length < 2;
if (cnt) cnt.textContent = selected.length;
}
function applyProjectLock() {
var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
allRows.forEach(function(r) {
if (lockedProject === null) {
r.classList.remove('row-locked');
} else {
var proj = r.dataset.project || '';
if (proj !== lockedProject) {
r.classList.add('row-locked');
} else {
r.classList.remove('row-locked');
}
}
});
}
function toggleRow(row) {
if (row.classList.contains('row-locked')) return;
var vid = row.dataset.vid || row.dataset.run;
var idx = selected.indexOf(vid);
if (idx >= 0) {
selected.splice(idx, 1);
row.classList.remove('selected');
var b = document.getElementById('badge-' + vid);
if (b) b.textContent = '';
// Release project lock if nothing selected
if (selected.length === 0) lockedProject = null;
} else {
// Set project lock on first selection
if (selected.length === 0) lockedProject = row.dataset.project || null;
selected.push(vid);
row.classList.add('selected');
}
selected.forEach(function(v, i) {
var b = document.getElementById('badge-' + v);
if (b) b.textContent = i + 1;
});
applyProjectLock();
updateCompareBtn();
buildScopePanel();
}
// ── Compare-All bar ───────────────────────────────────────────────────
function buildCompareAllBar() {
var bar = document.getElementById('compare-all-bar');
if (!bar) return;
// Group all rows by project label.
var groups = {};
var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
// Use all rows from the source data (not just visible).
var allRowsAll = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
// We need ALL rows across all pages, not just the rendered ones.
// Use the underlying allRows array that the pagination JS also uses.
var sourceRows = window._allCompareRows || allRowsAll;
sourceRows.forEach(function(r) {
var proj = r.dataset.project || '';
var vid = r.dataset.vid || r.dataset.run || '';
if (!proj || !vid) return;
if (!groups[proj]) groups[proj] = { ids: [], ts: [] };
groups[proj].ids.push(vid);
groups[proj].ts.push(parseInt(r.dataset.sortTs || '0', 10) || 0);
});
// Build buttons for each project with >= 2 scans.
var keys = Object.keys(groups).filter(function(k) { return groups[k].ids.length >= 2; });
if (!keys.length) { bar.style.display = 'none'; return; }
bar.style.display = 'flex';
// Remove old buttons (keep label).
var oldBtns = bar.querySelectorAll('.compare-all-btn');
oldBtns.forEach(function(b) { b.remove(); });
keys.sort();
keys.forEach(function(proj) {
var g = groups[proj];
var btn = document.createElement('button');
btn.className = 'compare-all-btn';
btn.type = 'button';
btn.textContent = proj + ' (' + g.ids.length + ' scans)';
btn.title = 'Compare all ' + g.ids.length + ' scans of ' + proj;
btn.addEventListener('click', function() {
// Sort ids by timestamp (ascending).
var pairs = g.ids.map(function(id, i) { return { id: id, ts: g.ts[i] }; });
pairs.sort(function(a, b) { return a.ts - b.ts; });
var sorted = pairs.map(function(p) { return p.id; });
if (sorted.length === 2) {
window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
} else {
window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
}
});
bar.appendChild(btn);
});
}
// ── Scope panel ───────────────────────────────────────────────────────
var selectedScope = 'all';
function buildScopePanel() {
var panel = document.getElementById('scope-panel');
var opts = document.getElementById('scope-options');
if (!panel || !opts) return;
if (selected.length < 2) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
// Collect union of submodules from all selected rows.
var allSubs = {};
selected.forEach(function(vid) {
var row = document.querySelector('#compare-tbody .compare-row[data-vid="' + vid + '"]');
if (!row) return;
(row.dataset.submodules || '').split(',').filter(Boolean).forEach(function(s) { allSubs[s] = true; });
});
var subList = Object.keys(allSubs).sort();
if (subList.length === 0) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
panel.classList.remove('hidden');
opts.innerHTML = '';
function makeOption(value, label, title) {
var div = document.createElement('div');
div.className = 'scope-option' + (selectedScope === value ? ' selected' : '');
div.dataset.scopeValue = value;
if (title) div.title = title;
var radio = document.createElement('span');
radio.className = 'scope-option-radio';
var lbl = document.createElement('span');
lbl.textContent = label;
div.appendChild(radio);
div.appendChild(lbl);
div.addEventListener('click', function() {
selectedScope = value;
opts.querySelectorAll('.scope-option').forEach(function(o) {
o.classList.toggle('selected', o.dataset.scopeValue === value);
});
});
return div;
}
opts.appendChild(makeOption('all', 'Full scan', 'All files — super-repo and submodules combined'));
var sep = document.createElement('span');
sep.className = 'scope-option-sep';
opts.appendChild(sep);
opts.appendChild(makeOption('super', 'Super-repo only', 'Only files not belonging to any submodule'));
subList.forEach(function(s) {
opts.appendChild(makeOption('sub:' + s, 'Submodule: ' + s, 'Only files belonging to submodule “' + s + '”'));
});
}
function doCompare() {
if (selected.length < 2) return;
if (selected.length === 2) {
// Two-scan delta (existing flow with scope support).
var url = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
if (selectedScope === 'super') url += '&scope=super';
else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
window.location.href = url;
} else {
// Multi-scan timeline (N >= 3) — pass scope params too.
var url = '/multi-compare?runs=' + selected.map(encodeURIComponent).join(',');
if (selectedScope === 'super') url += '&scope=super';
else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
window.location.href = url;
}
}
// ── Event wiring (CSP-safe: no inline handlers) ───────────────────────
var cbtn = document.getElementById('compare-btn');
if (cbtn) cbtn.addEventListener('click', doCompare);
var pfEl = document.getElementById('project-filter');
if (pfEl) pfEl.addEventListener('input', function() { currentPage = 1; renderPage(); });
var bfEl = document.getElementById('branch-filter');
if (bfEl) bfEl.addEventListener('change', function() { currentPage = 1; renderPage(); });
var rvBtn = document.getElementById('reset-view-btn');
if (rvBtn) rvBtn.addEventListener('click', function() { window.resetView(); });
var ppSel = document.getElementById('per-page-sel');
if (ppSel) ppSel.addEventListener('change', function() { perPage = parseInt(this.value, 10) || 25; currentPage = 1; renderPage(); });
var cmpTbody = document.getElementById('compare-tbody');
if (cmpTbody) cmpTbody.addEventListener('click', function(e) {
var row = e.target.closest('.compare-row');
if (row) toggleRow(row);
});
(function randomizeWatermarks() {
var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
if (!wms.length) return;
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),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;});
})();
(function spawnCodeParticles() {
var container = document.getElementById('code-particles');
if (!container) return;
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'];
for (var i = 0; i < 38; 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);
}
})();
// ── Watched folder picker ─────────────────────────────────────────────
(function() {
var btn = document.getElementById('add-watched-btn');
if (!btn) return;
btn.addEventListener('click', function() {
fetch('/pick-directory?kind=reports')
.then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
.then(function(data) {
if (!data.cancelled && data.selected_path) {
var form = document.createElement('form');
form.method = 'POST';
form.action = '/watched-dirs/add';
var ri = document.createElement('input');
ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
var fi = document.createElement('input');
fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
form.appendChild(ri); form.appendChild(fi);
document.body.appendChild(form);
form.submit();
}
})
.catch(function(e) { alert('Could not open folder picker: ' + e); });
});
})();
// ── Submodule chip truncation ─────────────────────────────────────────
document.querySelectorAll('.submod-chips-cell').forEach(function(cell) {
var chips = cell.querySelectorAll('.submod-chip');
var MAX = 4;
if (chips.length <= MAX) return;
for (var i = MAX; i < chips.length; i++) chips[i].style.display = 'none';
var badge = document.createElement('span');
badge.className = 'submod-overflow-badge';
badge.title = Array.from(chips).slice(MAX).map(function(c){return c.textContent;}).join(', ');
badge.textContent = '+' + (chips.length - MAX) + ' more';
cell.appendChild(badge);
cell.style.maxHeight = 'none';
});
})();
</script>
<script nonce="{{ csp_nonce }}">
(function(){
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'}];
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);});}
try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
function init(){
var btn=document.getElementById('settings-btn');if(!btn)return;
var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
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>';
document.body.appendChild(m);
var g=document.getElementById('scheme-grid');
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);});
var cl=document.getElementById('settings-close');
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);
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');});
if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
}
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
}());
</script>
<script nonce="{{ csp_nonce }}">(function(){var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';
if(location.protocol==='file:'){if(lbl)lbl.textContent='Offline';if(dot){dot.style.background='#888';dot.style.boxShadow='none';}if(pingEl)pingEl.textContent='';if(fm)fm.textContent='oxide-sloc v{{ version }} \u2014 Saved Report';var td=document.querySelector('.server-status-tip');if(td)td.textContent='Saved HTML report \u2014 server not connected.';return;}
if(lbl)lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{{ version }} — Mode: '+(isServer?'Network Server':'Local');function setDot(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}function doPing(){var t0=performance.now();fetch('/healthz',{cache:'no-store'}).then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}).catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});}doPing();setInterval(doPing,5000);})();</script>
</body>
</html>
"##,
ext = "html"
)]
struct CompareSelectTemplate {
version: &'static str,
entries: Vec<HistoryEntryRow>,
total_scans: usize,
watched_dirs: Vec<String>,
csp_nonce: String,
server_mode: bool,
}
// ── CompareTemplate ────────────────────────────────────────────────────────────
#[derive(Template)]
#[template(
source = r##"
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OxideSLOC | Scan Delta</title>
<link rel="icon" type="image/png" href="/images/logo/small-logo.png">
<style nonce="{{ csp_nonce }}">
:root {
--radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
--line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
--nav:#283790; --nav-2:#013e6b;
--accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
--pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6; --zero-bg:transparent;
--added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
}
body.dark-theme {
--bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
--muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e;
}
*{box-sizing:border-box;} html,body{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);} body{display:flex;flex-direction:column;}
.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);}
.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;}
.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));}
.brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
.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;}
.nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
@media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
@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; } }
.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;}
.theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
.theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
.theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
.theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
.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;}
.settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
.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);}
.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;}
.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;}
.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;}
.scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
.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;}
.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);}
.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;}
.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;}
.tz-select:focus{border-color:var(--oxide);}
.page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
@media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
.panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
.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;}
.hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
.hero-body{display:block;}
.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;}
.btn-back:hover{background:var(--line);}
h1{margin:0 0 6px;font-size:36px;font-weight:850;letter-spacing:-0.03em;}
h2{margin:0 0 14px;font-size:18px;font-weight:750;}
.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;}
.delta-desc{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}
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;}
.muted{color:var(--muted);font-size:14px;}
.version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
.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;}
.vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
.vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
.vpill-arrow{font-size:20px;color:var(--muted);}
.meta-strip{display:grid;grid-template-columns:1fr 1fr;gap:14px;width:100%;margin-bottom:14px;}
.delta-strip{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,1fr) minmax(110px,1fr) minmax(180px,1.5fr);gap:12px;width:100%;}
.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;}
.delta-card.delta-card-wide{padding:22px 24px;}
.delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);min-height:210px;justify-content:flex-start;padding:28px 30px;}
body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
.delta-card-label{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:12px;}
.delta-card-from{font-size:15px;color:var(--muted);}
.delta-card-to{font-size:28px;font-weight:800;margin:4px 0;}
.meta-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:12px;}
.meta-card-project-col{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:55%;min-width:0;}
.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%;}
.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;}
.meta-scope-tag svg{flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:2.2;}
.scope-full{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}
.scope-super{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.32);color:var(--oxide-2);}
.scope-sub{background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.32);color:var(--accent-2);}
body.dark-theme .scope-sub{background:rgba(111,155,255,0.18);border-color:rgba(111,155,255,0.38);color:var(--accent);}
body.dark-theme .scope-super{background:rgba(211,122,76,0.16);border-color:rgba(211,122,76,0.36);color:var(--oxide);}
.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;}
.meta-card-commit:hover{color:var(--oxide);}
.meta-card-rows{display:flex;flex-direction:column;gap:6px;}
.meta-card-row{display:flex;align-items:baseline;gap:8px;font-size:13px;}
.meta-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}
.meta-value{color:var(--text);font-size:13px;}
.cmp-author-handle{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}
.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.6;width:290px;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;}
.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);}
.delta-card:hover .dc-tip{display:block;}
.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;}
.export-btn:hover{background:var(--line);}
.export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
.panel-title{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}
.delta-card-change{font-size:15px;font-weight:700;border-radius:6px;padding:2px 8px;display:inline-block;margin-top:4px;}
.delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
.delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
.delta-card-change.zero{color:var(--muted);background:transparent;}
.delta-card-pct{font-size:14px;font-weight:700;margin-top:5px;letter-spacing:.01em;}
.delta-card-pct.pos{color:var(--pos);}
.delta-card-pct.neg{color:var(--neg);}
.delta-card-pct.zero{color:var(--muted);}
.insights-panel{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
.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;}
.insight-card.insight-flag{border-color:var(--oxide);}
.insight-card:hover .dc-tip{display:block;}
.dc-tip.up{top:auto;bottom:calc(100% + 8px);}
.dc-tip.up::after{bottom:auto;top:100%;border-bottom-color:transparent;border-top-color:rgba(20,12,8,0.96);}
.insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:4px;}
.insight-label.flag{color:var(--oxide);}
.insight-val{font-size:18px;font-weight:800;line-height:1.2;}
.insight-val.pos{color:var(--pos);}
.insight-val.neg{color:var(--neg);}
.insight-val.high{color:#c0392a;}
.insight-val.med{color:#926000;}
.insight-val.low{color:var(--pos);}
body.dark-theme .insight-val.high{color:#ff6b6b;}
body.dark-theme .insight-val.med{color:#f0c060;}
.insight-sub{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4;}
.file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
.fc-row{display:flex;align-items:center;gap:8px;}
.fc-count{font-weight:800;font-size:16px;min-width:28px;}
.fc-label{color:var(--muted);}
.fc-modified .fc-count{color:#926000;}
.fc-added .fc-count{color:var(--pos);}
.fc-removed .fc-count{color:var(--neg);}
.fc-unchanged .fc-count{color:var(--muted);}
body.dark-theme .fc-modified .fc-count{color:#f0c060;}
.change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
.chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
.chip.modified{background:#fff2d8;color:#926000;}
.chip.added{background:#e8f5ed;color:#1a8f47;}
.chip.removed{background:#fdeaea;color:#b33b3b;}
.chip.unchanged{background:var(--surface-2);color:var(--muted);}
body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
.filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
.filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
.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;}
.tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
.tab-btn:hover:not(.active){background:var(--line);}
.btn-reset{display:inline-flex;align-items:center;gap:5px;padding:5px 13px;border-radius:7px;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:12px;font-weight:700;cursor:pointer;transition:background .12s ease;white-space:nowrap;}
.btn-reset:hover{background:var(--line);}
.table-wrap{width:100%;overflow-x:auto;}
table{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}
th{text-align:left;font-size:10px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);padding:8px 10px;border-bottom:2px solid var(--line);white-space:nowrap;position:relative;user-select:none;background:var(--surface-2);}
th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
.sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
.col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
.col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
td{padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:middle;white-space:nowrap;}
tr:last-child td{border-bottom:none;}
tr:hover td{background:var(--surface-2);}
.col-num{text-align:right;font-variant-numeric:tabular-nums;}
#delta-table th:nth-child(n+4),#delta-table td:nth-child(n+4){text-align:right;font-variant-numeric:tabular-nums;}
#delta-table th:last-child,#delta-table td:last-child{padding-right:14px;}
tr.row-added td{background:rgba(26,143,71,0.04);}
tr.row-removed td{background:rgba(179,59,59,0.06);}
tr.row-modified td{background:rgba(146,96,0,0.04);}
tr.row-unchanged td{color:var(--muted);}
tr.row-unchanged .status-badge{opacity:.65;}
.file-path{font-family:ui-monospace,monospace;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:340px;display:inline-block;vertical-align:middle;}
.status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
.status-badge.added{background:#e8f5ed;color:#1a8f47;}
.status-badge.removed{background:#fdeaea;color:#b33b3b;}
.status-badge.modified{background:#fff2d8;color:#926000;}
.status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
.delta-val{font-weight:700;}
.delta-val.pos{color:var(--pos);}
.delta-val.neg{color:var(--neg);}
.delta-val.zero{color:var(--muted);}
.from-to{display:flex;align-items:center;gap:5px;white-space:nowrap;font-size:13px;}
.from-to strong{color:var(--text);font-weight:700;}
.from-to .ft-sep{color:var(--muted-2);font-size:11px;}
.from-to .ft-absent{color:var(--muted);font-weight:600;}
.site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
.site-footer a{color:var(--muted);}
body.pdf-mode .top-nav,body.pdf-mode .background-watermarks,body.pdf-mode #code-particles,body.pdf-mode .export-group,body.pdf-mode .btn-reset,body.pdf-mode .filter-tabs,body.pdf-mode .filter-tabs-row,body.pdf-mode .pagination,body.pdf-mode select.per-page,body.pdf-mode .settings-modal,body.pdf-mode .site-footer,body.pdf-mode .scope-bar,body.pdf-mode .submod-scope-bar{display:none!important;}
body.pdf-mode{background:#fff!important;}
@media(max-width:900px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:repeat(2,1fr);}}
@media(max-width:600px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
.background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
.background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
.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;}
.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;}
.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;}
@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));}}
.path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
.path-link:hover{color:var(--oxide-2);}
.vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
a.vpill-id:hover{color:var(--oxide);}
.delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
.pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
.pagination-info{font-size:13px;color:var(--muted);}
.pagination-btns{display:flex;gap:6px;}
.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;}
.pg-btn:hover:not(:disabled){background:var(--line);}
.pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
.pg-btn:disabled{opacity:.35;cursor:default;}
.per-page-label{font-size:13px;color:var(--muted);}
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;}
.tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
.tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
.tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
.tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
.tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
.tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
.tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
.tab-btn.tab-unchanged{color:var(--muted);}
body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
.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;}
.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;}
.submod-scope-divider{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}
.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;}
.submod-scope-label svg{stroke:currentColor;fill:none;stroke-width:2;}
.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;}
.submod-scope-btn:hover{background:var(--line);}
.submod-scope-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
.submod-scope-hint{font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;}
.ic-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
@media(max-width:800px){.ic-grid{grid-template-columns:1fr;}}
.ic-card{background:var(--surface-2);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}
body.dark-theme .ic-card{background:var(--surface-2);}
.ic-card-h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}
.ic-leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}
.ic-leg-item{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}
.ic-leg-item:hover{background:rgba(211,122,76,0.08);}
.ic-dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}
.ic-cb{cursor:pointer;transition:filter .15s;}.ic-cb:hover{filter:brightness(1.12);}
.ic-card-h2-row{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;}
.ic-card-h2-row .ic-card-h2{margin:0;}
.chart-metric-btn{padding:5px 13px;border-radius:7px;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:12px;font-weight:700;cursor:pointer;transition:background .12s;}
.chart-metric-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
.chart-metric-btn:hover:not(.active){background:var(--line);}
.chart-wrap{width:100%;overflow-x:auto;}
#cmp-tl-svg{display:block;width:100%;}
.git-chip{font-family:ui-monospace,monospace;font-size:11px;font-weight:700;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);}
body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
#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;}
</style>
</head>
<body>
<div class="background-watermarks" aria-hidden="true">
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
</div>
<div class="code-particles" id="code-particles" aria-hidden="true"></div>
<div class="top-nav">
<div class="top-nav-inner">
<a class="brand" href="/">
<img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
<div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan Delta</div></div>
</a>
<div class="nav-right">
<a class="nav-pill" href="/">Home</a>
<div class="nav-dropdown">
<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>
<div class="nav-dropdown-menu">
<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>
</div>
</div>
<a class="nav-pill" href="/compare-scans">Compare Scans</a>
<a class="nav-pill" href="/test-metrics">Test Metrics</a>
<div class="nav-dropdown">
<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>
<div class="nav-dropdown-menu">
<a href="/integrations"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
</div>
</div>
<div class="server-status-wrap" id="server-status-wrap">
<div class="nav-pill server-online-pill" id="server-status-pill">
<span class="status-dot" id="status-dot"></span>
<span id="server-status-label">Server</span>
<span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
</div>
<div class="server-status-tip">
OxideSLOC is running — accessible on your network.
<span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
</div>
</div>
<button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
<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>
</button>
<button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
<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>
<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>
</button>
</div>
</div>
</div>
<div class="page">
<section class="hero">
<div class="hero-header">
<div>
<h1 class="delta-title">Scan Delta</h1>
<p class="delta-desc">Side-by-side metric comparison between two scans — code line deltas, file changes, and language breakdown.</p>
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:6px;">
{% if let Some(sub) = active_submodule %}
<span class="muted" style="font-size:16px;">Submodule <strong>{{ sub }}</strong> — two scans of</span>
{% else if super_scope_active %}
<span class="muted" style="font-size:16px;">Super-repo only (submodules excluded) — two scans of</span>
{% else %}
<span class="muted" style="font-size:16px;">Full scan — two scans of</span>
{% endif %}
<a class="path-link" id="project-path-link" data-folder="{{ project_path }}" href="#" style="font-size:16px;font-weight:700;">{{ project_path }}</a>
</div>
</div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;flex-shrink:0;">
<a class="btn-back" href="/compare-scans">
<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>
Compare Scans
</a>
<div class="export-group" style="margin-top:12px;">
<button type="button" class="export-btn" id="page-export-html-btn" title="Export page as HTML report"><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> Export HTML</button>
<button type="button" class="export-btn" id="page-export-pdf-btn" title="Export page as PDF report"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg> Export PDF</button>
</div>
</div>
</div>
{% if has_any_submodule_data %}
<div class="submod-scope-bar">
<span class="submod-scope-label">
<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>
Scope:
</span>
<div class="submod-scope-divider"></div>
<a class="submod-scope-btn{% if active_submodule.is_none() && !super_scope_active %} active{% endif %}"
href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}"
title="All files — super-repo and all submodules combined">Full scan</a>
<a class="submod-scope-btn{% if super_scope_active %} active{% endif %}"
href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&scope=super"
title="Only files that are not part of any submodule">Super-repo only</a>
{% for sub in submodule_options %}
<a class="submod-scope-btn{% if active_submodule.as_deref() == Some(sub.as_str()) %} active{% endif %}"
href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&sub={{ sub }}"
title="Only files belonging to submodule {{ sub }}">{{ sub }}</a>
{% endfor %}
</div>
{% endif %}
<div class="hero-body">
<div class="meta-strip">
<div class="delta-card delta-card-meta">
<div class="meta-card-header">
<div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Baseline</div>
<div class="meta-card-project-col">
<div class="meta-card-project">{{ project_name }}</div>
{% if has_any_submodule_data %}
{% if let Some(sub) = active_submodule %}
<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>
{% else if super_scope_active %}
<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>
{% else %}
<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>
{% endif %}
{% endif %}
</div>
</div>
{% if !baseline_git_commit.is_empty() %}
<a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_git_commit }}</a>
{% else %}
<a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_run_id_short }}</a>
{% endif %}
<div class="meta-card-rows">
<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>
<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>
<div class="meta-card-row"><span class="meta-label">Last commit by:</span>{% if let Some(author) = baseline_git_author %}<span class="meta-value"><span class="cmp-author-val">{{ author }}</span><span class="cmp-author-handle"></span></span>{% else %}<span class="meta-value">—</span>{% endif %}</div>
<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>
{% if let Some(tags) = baseline_git_tags %}
<div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
{% endif %}
</div>
</div>
<div class="delta-card delta-card-meta">
<div class="meta-card-header">
<div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Current</div>
<div class="meta-card-project-col">
<div class="meta-card-project">{{ project_name }}</div>
{% if has_any_submodule_data %}
{% if let Some(sub) = active_submodule %}
<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>
{% else if super_scope_active %}
<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>
{% else %}
<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>
{% endif %}
{% endif %}
</div>
</div>
{% if !current_git_commit.is_empty() %}
<a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_git_commit }}</a>
{% else %}
<a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_run_id_short }}</a>
{% endif %}
<div class="meta-card-rows">
<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>
<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>
<div class="meta-card-row"><span class="meta-label">Last commit by:</span>{% if let Some(author) = current_git_author %}<span class="meta-value"><span class="cmp-author-val">{{ author }}</span><span class="cmp-author-handle"></span></span>{% else %}<span class="meta-value">—</span>{% endif %}</div>
<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>
{% if let Some(tags) = current_git_tags %}
<div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
{% endif %}
</div>
</div>
</div>
<div class="delta-strip">
<div class="delta-card">
<div class="dc-tip">Executable source lines.<br>Excludes comments and blanks.<br>Positive delta = more code written.</div>
<div class="delta-card-label">Code lines</div>
<div class="delta-card-from">Before: {{ baseline_code_fmt }}</div>
<div class="delta-card-to">{{ current_code_fmt }}</div>
{% 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>
{% 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>
{% else %}<div class="delta-card-pct zero">±0%</div>
{% endif %}
</div>
<div class="delta-card">
<div class="dc-tip">Source files where language detection succeeded.<br>Changes reflect files added, removed, or reclassified between scans.</div>
<div class="delta-card-label">Files analyzed</div>
<div class="delta-card-from">Before: {{ baseline_files_fmt }}</div>
<div class="delta-card-to">{{ current_files_fmt }}</div>
{% 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>
{% 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>
{% else %}<div class="delta-card-pct zero">±0%</div>
{% endif %}
</div>
<div class="delta-card">
<div class="dc-tip">Comment-only lines per the active parser policy.<br>A rise indicates more docs; a drop may reflect comment cleanup.</div>
<div class="delta-card-label">Comment lines</div>
<div class="delta-card-from">Before: {{ baseline_comments_fmt }}</div>
<div class="delta-card-to">{{ current_comments_fmt }}</div>
{% 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>
{% 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>
{% else %}<div class="delta-card-pct zero">±0%</div>
{% endif %}
</div>
{{ coverage_delta_card|safe }}
<div class="delta-card delta-card-wide">
<div class="dc-tip">Per-file breakdown.<br>Modified = at least one count changed.<br>Unchanged = identical counts in both scans.<br>Added/Removed = only in one scan.</div>
<div class="delta-card-label">File changes</div>
<div class="file-changes-grid">
<div class="fc-row fc-modified"><span class="fc-count">{{ files_modified }}</span><span class="fc-label">Modified</span></div>
<div class="fc-row fc-added"><span class="fc-count">{{ files_added }}</span><span class="fc-label">Added</span></div>
<div class="fc-row fc-removed"><span class="fc-count">{{ files_removed }}</span><span class="fc-label">Removed</span></div>
<div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
</div>
</div>
</div>
<div class="insights-panel">
<div class="insight-card">
<div class="dc-tip up">Sum of code lines added or grown across all files between the two scans.<br>Only counts files where the current scan has more code than the baseline — shrunk files do not contribute here.</div>
<div class="insight-label">Lines Added</div>
<div class="insight-val pos">+{{ code_lines_added }}</div>
<div class="insight-sub">New or grown source lines</div>
</div>
<div class="insight-card">
<div class="dc-tip up">Sum of code lines removed or shrunk across all files between the two scans.<br>Only counts files where the current scan has fewer code lines than the baseline — grown files do not contribute here.</div>
<div class="insight-label">Lines Removed</div>
<div class="insight-val neg">−{{ code_lines_removed }}</div>
<div class="insight-sub">Deleted or shrunk source lines</div>
</div>
<div class="insight-card">
<div class="dc-tip up">Measures total editing activity relative to codebase size.<br>Formula: (lines added + lines removed) ÷ baseline code lines × 100%.<br>Above 20% = high activity<br>5–20% = normal velocity<br>Below 5% = stable baseline.</div>
<div class="insight-label">Churn Rate</div>
<div class="insight-val {{ churn_rate_class }}">{{ churn_rate_str }}</div>
<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>
</div>
{% if scope_flag %}
<div class="insight-card insight-flag">
<div class="dc-tip up">{% if new_scope %}This scope had no files in the baseline scan — all content is new.<br>Switch to Full scan to compare against the parent repository.{% else %}Triggered when net code growth exceeds 20% of the baseline.<br>This often signals a large feature branch, a bulk import, or a generated-file inclusion.<br>Review the file-level delta below to confirm scope.{% endif %}</div>
<div class="insight-label flag">Scope Signal</div>
<div class="insight-val high">{% if new_scope %}New{% else %}{{ code_lines_pct_str }}{% endif %}</div>
<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>
</div>
{% endif %}
</div>
</div>
</section>
<section class="panel" id="inline-charts-section">
<div class="panel-title">Scan Delta Charts</div>
<div class="ic-grid">
<div class="ic-card" style="grid-column:span 2">
<div class="ic-card-h2-row">
<span class="ic-card-h2">Timeline</span>
<div class="cmp-tl-btns" style="display:flex;gap:6px;flex-wrap:wrap;">
<button class="chart-metric-btn active" data-cmp-metric="code">Code Lines</button>
<button class="chart-metric-btn" data-cmp-metric="files">Files</button>
<button class="chart-metric-btn" data-cmp-metric="comments">Comments</button>
<button class="chart-metric-btn" data-cmp-metric="tests">Tests</button>
<button class="chart-metric-btn" data-cmp-metric="cov">Coverage</button>
</div>
</div>
<div class="chart-wrap"><svg id="cmp-tl-svg" width="100%" height="280"></svg></div>
</div>
<div class="ic-card">
<div class="ic-card-h2">Code Metrics — Baseline vs Current</div>
<div class="ic-leg"><span class="ic-leg-item" data-highlight="Code Lines"><span class="ic-dot" style="background:#93C5FD"></span><span style="color:#2563EB;font-weight:600">Code Lines</span></span><span class="ic-leg-item" data-highlight="Files Analyzed"><span class="ic-dot" style="background:#C4B5FD"></span><span style="color:#7C3AED;font-weight:600">Files</span></span><span class="ic-leg-item" data-highlight="Comments"><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>
<div id="ic-c1"></div>
</div>
<div class="ic-card" id="ic-lang-card">
<div class="ic-card-h2">Language Code Delta</div>
<div id="ic-c3"></div>
</div>
<div class="ic-card">
<div class="ic-card-h2">Delta by Metric</div>
<div id="ic-c2"></div>
</div>
<div class="ic-card">
<div class="ic-card-h2">File Change Distribution</div>
<div id="ic-c4"></div>
</div>
</div>
</section>
<section class="panel">
<div class="panel-title">File Matrix <span style="font-size:11px;font-weight:400;color:var(--muted);margin-left:8px;text-transform:none;letter-spacing:0;">{{ files_modified + files_added + files_removed + files_unchanged }} files</span></div>
<div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:14px;">
<div class="filter-tabs" style="display:flex;gap:6px;flex-wrap:wrap;">
<button class="tab-btn tab-all active" data-filter="all">All ({{ files_modified + files_added + files_removed + files_unchanged }})</button>
<button class="tab-btn tab-modified" data-filter="modified">Modified ({{ files_modified }})</button>
<button class="tab-btn tab-added" data-filter="added">Added ({{ files_added }})</button>
<button class="tab-btn tab-removed" data-filter="removed">Removed ({{ files_removed }})</button>
<button class="tab-btn tab-unchanged" data-filter="unchanged">Unchanged ({{ files_unchanged }})</button>
</div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
<span class="delta-note">* Δ = delta (change from baseline → current)</span>
<div class="export-group">
<button type="button" class="export-btn" id="delta-reset-btn">↻ Reset</button>
<button type="button" class="export-btn" id="delta-csv-btn">
<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>
CSV
</button>
<button type="button" class="export-btn" id="delta-xls-btn">
<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>
Excel
</button>
</div>
</div>
</div>
<div class="table-wrap">
<table id="delta-table">
<colgroup>
<col>
<col>
<col>
<col>
<col>
<col>
<col>
</colgroup>
<thead>
<tr id="delta-thead">
<th class="sortable" data-sort-col="path" data-sort-type="str">File<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
<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>
<th class="sortable" data-sort-col="status" data-sort-type="str">Status<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
<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>
<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>
<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>
<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>
</tr>
</thead>
<tbody id="delta-tbody">
{% for row in file_rows %}
<tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
data-path="{{ row.relative_path }}"
data-language="{{ row.language }}"
data-baseline-code="{{ row.baseline_code }}"
data-current-code="{{ row.current_code }}"
data-code-delta="{{ row.code_delta_str }}"
data-comment-delta="{{ row.comment_delta_str }}"
data-total-delta="{{ row.total_delta_str }}"
data-orig-idx="">
<td title="{{ row.relative_path }}"><span class="file-path">{{ row.relative_path }}</span></td>
<td class="hide-sm">{{ row.language }}</td>
<td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
<td><span class="from-to" data-baseline="{{ row.baseline_code }}" data-current="{{ row.current_code }}">{% if row.baseline_code_display == "—" %}<span class="ft-absent">—</span>{% else %}<strong>{{ row.baseline_code_display }}</strong>{% endif %}<span class="ft-sep">→</span>{% if row.current_code_display == "—" %}<span class="ft-absent">—</span>{% else %}<strong>{{ row.current_code_display }}</strong>{% endif %}</span></td>
<td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
<td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
<td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="pagination">
<span class="pagination-info" id="pg-range-label"></span>
<div class="pagination-btns" id="pg-btns"></div>
<div class="flex-row">
<span class="per-page-label">Show</span>
<select class="per-page" id="per-page-sel">
<option value="10">10 per page</option>
<option value="25" selected>25 per page</option>
<option value="50">50 per page</option>
<option value="100">100 per page</option>
</select>
</div>
</div>
</section>
</div>
<div id="ic-tt"></div>
<footer class="site-footer">
local code analysis - metrics, history and reports
· <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} \u2014 Mode: Local</em>
· Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
· <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
· <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
· <a href="/api-docs" rel="noopener">REST API</a>
</footer>
<script nonce="{{ csp_nonce }}">
(function () {
var storageKey = 'oxide-sloc-theme';
var body = document.body;
try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
var toggle = document.getElementById('theme-toggle');
if (toggle) toggle.addEventListener('click', function () {
var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
body.classList.toggle('dark-theme', next === 'dark');
try { localStorage.setItem(storageKey, next); } catch(e) {}
});
(function randomizeWatermarks() {
var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
if (!wms.length) return;
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),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;});
})();
(function spawnCodeParticles() {
var container = document.getElementById('code-particles');
if (!container) return;
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'];
for (var i = 0; i < 38; 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);
}
})();
})();
var activeStatusFilter = 'all';
var deltaPerPage = 25, deltaCurrPage = 1;
function openFolder(path) {
fetch('/open-path?path=' + encodeURIComponent(path))
.then(function (r) { return r.json(); })
.then(function (d) {
if (d && d.server_mode_disabled) window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
})
.catch(function () {});
}
function getDeltaFilteredRows() {
return Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).filter(function(r) {
return activeStatusFilter === 'all' || r.getAttribute('data-status') === activeStatusFilter;
});
}
function renderDeltaPage() {
var filtered = getDeltaFilteredRows();
var total = filtered.length;
var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
deltaCurrPage = Math.min(deltaCurrPage, totalPages);
var start = (deltaCurrPage - 1) * deltaPerPage;
var end = Math.min(start + deltaPerPage, total);
var shownSet = {};
filtered.slice(start, end).forEach(function(r) { shownSet[r.dataset.origIdx] = true; });
Array.prototype.slice.call(document.querySelectorAll('#delta-tbody .delta-row')).forEach(function(r) {
r.style.display = shownSet[r.dataset.origIdx] !== undefined ? '' : 'none';
});
var rl = document.getElementById('pg-range-label');
if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '–' + end + ' of ' + total + ' files' : 'No results';
var btns = document.getElementById('pg-btns');
if (!btns) return;
btns.innerHTML = '';
if (totalPages <= 1) return;
function makeBtn(lbl, pg, active, disabled) {
var b = document.createElement('button');
b.className = 'pg-btn' + (active ? ' active' : '');
b.textContent = lbl; b.disabled = disabled;
if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
return b;
}
btns.appendChild(makeBtn('‹', deltaCurrPage - 1, false, deltaCurrPage === 1));
var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
btns.appendChild(makeBtn('›', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
}
window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
function filterRows(status, btn) {
activeStatusFilter = status;
deltaCurrPage = 1;
Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
b.classList.remove('active');
});
if (btn) btn.classList.add('active');
renderDeltaPage();
}
// ── Sorting ──────────────────────────────────────────────────────────────
var sortCol = null, sortOrder = 'asc';
var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
(function() {
var tbody = document.getElementById('delta-tbody');
if (!tbody) return;
var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
rows.forEach(function(r, i) { r.dataset.origIdx = i; });
})();
function parseDeltaNum(str) {
if (!str || str === '\u2014') return 0;
return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().startsWith('-') ? -1 : 1);
}
sortHeaders.forEach(function(th) {
th.addEventListener('click', function(e) {
if (e.target.classList.contains('col-resize-handle')) return;
var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
th.classList.add('sort-' + sortOrder);
var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '↑' : '↓';
var tbody = document.getElementById('delta-tbody');
if (!tbody) return;
var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
rows.sort(function(a, b) {
var va, vb;
if (col === 'path') { va = a.dataset.path || ''; vb = b.dataset.path || ''; }
else if (col === 'language') { va = a.dataset.language || ''; vb = b.dataset.language || ''; }
else if (col === 'status') { va = a.dataset.status || ''; vb = b.dataset.status || ''; }
else if (col === 'baseline_code') { va = parseFloat(a.dataset.baselineCode || 0); vb = parseFloat(b.dataset.baselineCode || 0); return sortOrder === 'asc' ? va - vb : vb - va; }
else if (col === 'code_delta') { va = parseDeltaNum(a.dataset.codeDelta); vb = parseDeltaNum(b.dataset.codeDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
else if (col === 'comment_delta') { va = parseDeltaNum(a.dataset.commentDelta); vb = parseDeltaNum(b.dataset.commentDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
else if (col === 'total_delta') { va = parseDeltaNum(a.dataset.totalDelta); vb = parseDeltaNum(b.dataset.totalDelta); return sortOrder === 'asc' ? va - vb : vb - va; }
else { va = ''; vb = ''; }
if (sortOrder === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
return va < vb ? 1 : va > vb ? -1 : 0;
});
rows.forEach(function(r) { tbody.appendChild(r); });
deltaCurrPage = 1;
renderDeltaPage();
var activeBtn = document.querySelector('.tab-btn.active');
Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
if (activeBtn) activeBtn.classList.add('active');
});
});
// ── Column resize ─────────────────────────────────────────────────────────
(function() {
var table = document.getElementById('delta-table');
if (!table) return;
var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
ths.forEach(function(th, i) {
var handle = th.querySelector('.col-resize-handle');
if (!handle || !cols[i]) return;
var startX, startW;
handle.addEventListener('mousedown', function(e) {
e.stopPropagation(); e.preventDefault();
startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
handle.classList.add('dragging');
function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
});
})();
// ── Reset ─────────────────────────────────────────────────────────────────
window.resetDeltaTable = function() {
sortCol = null; sortOrder = 'asc';
sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '↕'; t.classList.remove('sort-asc', 'sort-desc'); });
var tbody = document.getElementById('delta-tbody');
if (tbody) {
var rows = Array.prototype.slice.call(tbody.querySelectorAll('.delta-row'));
rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
rows.forEach(function(r) { tbody.appendChild(r); });
}
var table = document.getElementById('delta-table');
if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
activeStatusFilter = 'all';
deltaCurrPage = 1;
Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
var allBtn = document.querySelector('.tab-btn');
if (allBtn) allBtn.classList.add('active');
renderDeltaPage();
};
renderDeltaPage();
// Compact number formatter (shared by the delta table; charts define their own locally)
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(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}
function fmtFull(n){return Number(n).toLocaleString();}
// Format from-to numbers with fmt() and ensure zero→dash for added/removed
function fmtFromTo() {
var tbody = document.getElementById('delta-tbody');
if (!tbody) return;
tbody.querySelectorAll('.delta-row').forEach(function(row) {
var status = row.dataset.status || '';
var ft = row.querySelector('.from-to');
if (!ft) return;
var bv = parseInt(ft.getAttribute('data-baseline') || '0', 10);
var cv = parseInt(ft.getAttribute('data-current') || '0', 10);
var strongs = ft.querySelectorAll('strong');
// Apply fmt() to non-absent strong values
strongs.forEach(function(el) {
var n = parseInt(el.textContent, 10);
if (!isNaN(n)) el.textContent = fmt(n);
});
// Safety: force dash for genuinely absent sides
if (status === 'added' && bv === 0) {
var bs = ft.querySelector('strong:first-of-type');
if (bs && bs.textContent === '0') {
bs.outerHTML = '<span class="ft-absent">\u2014</span>';
}
}
if (status === 'removed' && cv === 0) {
var cs = ft.querySelector('strong:last-of-type');
if (cs && cs.textContent === '0') {
cs.outerHTML = '<span class="ft-absent">\u2014</span>';
}
}
});
}
fmtFromTo();
// ── Event wiring (CSP-safe: no inline handlers) ───────────────────────────
(function() {
Array.prototype.slice.call(document.querySelectorAll('.tab-btn[data-filter]')).forEach(function(btn) {
btn.addEventListener('click', function() { filterRows(btn.dataset.filter, btn); });
});
var resetBtn = document.getElementById('delta-reset-btn');
if (resetBtn) resetBtn.addEventListener('click', function() { window.resetDeltaTable(); });
var csvBtn = document.getElementById('delta-csv-btn');
if (csvBtn) csvBtn.addEventListener('click', function() { window.exportDeltaCsv(); });
var xlsBtn = document.getElementById('delta-xls-btn');
if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportDeltaXls(); });
// ── Export helpers (image-inlining + pdf-mode) ────────────────────────────
function sdFetchUri(path) {
return fetch(path).then(function(r){return r.blob();}).then(function(b){
return new Promise(function(res){var rd=new FileReader();rd.onload=function(){res(rd.result);};rd.onerror=function(){res('');};rd.readAsDataURL(b);});
}).catch(function(){return '';});
}
function sdInlineImgs(html, cb) {
var paths=[], seen={};
html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){if(!seen[p]){seen[p]=1;paths.push(p);}return _;});
if(!paths.length){cb(html);return;}
Promise.all(paths.map(function(p){return sdFetchUri(p).then(function(u){return{p:p,u:u};});}))
.then(function(rs){rs.forEach(function(r){if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');});cb(html);})
.catch(function(){cb(html);});
}
function buildFullPageHtml(pdfMode) {
if(pdfMode) document.body.classList.add('pdf-mode');
var saved = deltaPerPage; deltaPerPage = 999999; deltaCurrPage = 1;
renderDeltaPage();
var html = document.documentElement.outerHTML;
deltaPerPage = saved; deltaCurrPage = 1; renderDeltaPage();
if(pdfMode) document.body.classList.remove('pdf-mode');
return html;
}
var chartsBtn = document.getElementById('delta-charts-btn');
if (chartsBtn) chartsBtn.addEventListener('click', function() {
var btn=chartsBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
sdInlineImgs(buildFullPageHtml(false), function(html) {
var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
var a=document.createElement('a');a.href=URL.createObjectURL(blob);
a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
btn.disabled=false;btn.innerHTML=orig;
});
});
var pageHtmlBtn = document.getElementById('page-export-html-btn');
if (pageHtmlBtn) pageHtmlBtn.addEventListener('click', function() {
var btn=pageHtmlBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
sdInlineImgs(buildFullPageHtml(false), function(html) {
var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
var a=document.createElement('a');a.href=URL.createObjectURL(blob);
a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
btn.disabled=false;btn.innerHTML=orig;
});
});
// PDF export — clean document-style report, not a web page screenshot
function buildDeltaPdfHtml() {
var sd=_sd, dr=getDeltaExportRows();
var projEl=document.querySelector('[data-folder]'), proj=projEl?projEl.getAttribute('data-folder'):'';
var projName=proj?(String(proj).replace(/[\\/]+$/,'').split(/[\\/]/).pop()||proj):proj;
var tz;try{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){tz='America/Los_Angeles';}
var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
function fmtN(n){var v=Number(n),a=Math.abs(v);if(a>=1e6)return(v/1e6).toFixed(1).replace(/\.0$/,'')+'M';if(a>=1e4)return(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}
function fullN(n){var v=Number(n);return isNaN(v)?'\u2014':v.toLocaleString();}
function delt(v){var s=String(v==null?'\u2014':v);if(!s||s==='0'||s==='\u2014')return'<span>'+esc(s)+'</span>';return s.charAt(0)==='-'?'<span style="color:#b23030;font-weight:700">'+esc(s)+'</span>':'<span style="color:#2a6846;font-weight:700">'+esc(s)+'</span>';}
var lm={};
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;});
var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,15);
var tfTotal=sd.fm+sd.fa+sd.fr+sd.fu;
var css='body{margin:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}'+
'.hdr{background:#1a2035;color:#fff;padding:16px 24px;display:flex;justify-content:space-between;align-items:flex-start;}'+
'.brand{font-size:13px;font-weight:800;color:#c45c10;letter-spacing:.06em;}'+
'.title{font-size:20px;font-weight:700;margin:3px 0 2px;line-height:1.2;}'+
'.proj{font-size:12px;color:#99aabb;margin-top:3px;}'+
'.hr{font-size:11px;color:#8899aa;text-align:right;line-height:1.9;}'+
'.body{padding:18px 24px;}'+
'.sg{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:16px;}'+
'.sc{border:1px solid #ddd;border-radius:8px;padding:10px 12px;}'+
'.sv{font-size:18px;font-weight:900;color:#c45c10;}'+
'.sl{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}'+
'.meta{background:#f5f2ee;border:1px solid #e5e0d8;border-radius:6px;padding:12px 16px;margin-bottom:14px;display:flex;justify-content:space-between;align-items:center;gap:10px;text-align:center;}'+
'.meta>div{flex:1 1 0;}'+
'.ml{color:#888;font-size:10px;text-transform:uppercase;letter-spacing:.06em;}.mv{font-weight:700;margin-top:4px;font-size:15px;}'+
'.sec{margin-bottom:18px;}'+
'.sh{background:#1a2035;color:#fff;padding:5px 10px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;margin:0;}'+
'table{width:100%;border-collapse:collapse;font-size:12px;}'+
'th{background:#1a2035;color:#fff;padding:5px 10px;font-size:11px;font-weight:700;text-align:left;letter-spacing:.03em;}'+
'td{border-bottom:1px solid #eee;padding:5px 10px;vertical-align:middle;}'+
'tr:nth-child(even) td{background:#faf8f6;}'+
'.ftr{background:#1a2035;color:#7a8b9c;font-size:10px;padding:7px 24px;display:flex;justify-content:space-between;margin-top:16px;}';
var fileRows=dr.slice(0,200).map(function(r){
var st=r[2]||'',ss=st==='added'?'color:#2a6846;font-weight:700':st==='removed'?'color:#b23030;font-weight:700':'';
return '<tr><td style="word-break:break-all">'+esc(r[0])+'</td><td>'+esc(r[1])+'</td>'+
'<td style="'+ss+'">'+esc(st)+'</td>'+
'<td style="text-align:right">'+fmtN(r[3])+'</td>'+
'<td style="text-align:right">'+fmtN(r[4])+'</td>'+
'<td style="text-align:right">'+delt(r[5])+'</td></tr>';
}).join('');
var more=dr.length>200?'<tr><td colspan="6" style="color:#888;font-style:italic;text-align:center">\u2026 '+fmtN(dr.length-200)+' more files \u2014 export to XLS for full list</td></tr>':'';
var langRows=langs.map(function(l){var e=lm[l],dv=e.d>=0?'+'+e.d:String(e.d);return'<tr><td>'+esc(l)+'</td><td style="text-align:right">'+fmtN(e.f)+'</td><td style="text-align:right">'+delt(dv)+'</td></tr>';}).join('');
return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>OxideSLOC \u2014 Scan Delta</title><style>'+css+'</style></head><body>'+
'<div class="hdr"><div><div class="brand">oxide-sloc</div><div class="title">Scan Delta</div><div class="proj">'+esc(projName)+'</div></div>'+
'<div class="hr">'+esc(_blabel)+'<br>'+esc(_clabel)+'<br>Generated: '+esc(now)+'</div></div>'+
'<div class="body">'+
'<div class="sg">'+
'<div class="sc"><div class="sv">'+delt(sd.cd)+'</div><div class="sl">Code Lines \u0394</div></div>'+
'<div class="sc"><div class="sv">'+delt(sd.fd)+'</div><div class="sl">Files \u0394</div></div>'+
'<div class="sc"><div class="sv">'+delt(sd.cmd)+'</div><div class="sl">Comment Lines \u0394</div></div>'+
'<div class="sc"><div class="sv" style="color:#111">'+fmtN(tfTotal)+'</div><div class="sl">Total Files</div></div>'+
'</div>'+
'<div class="meta">'+
'<div><div class="ml">Baseline Code</div><div class="mv">'+fullN(sd.bc)+'</div></div>'+
'<div><div class="ml">Current Code</div><div class="mv">'+fullN(sd.cc)+'</div></div>'+
'<div><div class="ml">Modified</div><div class="mv">'+fullN(sd.fm)+'</div></div>'+
'<div><div class="ml">Added</div><div class="mv" style="color:#2a6846">+'+fullN(sd.fa)+'</div></div>'+
'<div><div class="ml">Removed</div><div class="mv" style="color:#b23030">-'+fullN(sd.fr)+'</div></div>'+
'<div><div class="ml">Unchanged</div><div class="mv">'+fullN(sd.fu)+'</div></div>'+
'</div>'+
(langs.length?'<div class="sec"><p class="sh">Language Breakdown</p><table><thead><tr><th>Language</th><th style="text-align:right">Files Changed</th><th style="text-align:right">Code \u0394</th></tr></thead><tbody>'+langRows+'</tbody></table></div>':'')+
'<div class="sec"><p class="sh">File Delta ('+fmtN(dr.length)+' files)</p>'+
'<table><thead><tr><th>File</th><th>Language</th><th>Status</th>'+
'<th style="text-align:right">Code Before</th><th style="text-align:right">Code After</th><th style="text-align:right">Code \u0394</th>'+
'</tr></thead><tbody>'+fileRows+more+'</tbody></table></div>'+
'</div>'+
'<div class="ftr"><span>oxide-sloc v{{ version }}</span><span>Scan Delta Report</span>'+
'<span>'+esc(sd.bid)+' \u2192 '+esc(sd.cid)+'</span></div>'+
'</body></html>';
}
function doDeltaPdf(btn) {
var orig=btn.innerHTML;btn.disabled=true;btn.textContent='Generating PDF\u2026';
var html=buildDeltaPdfHtml();
fetch('/export/pdf',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({html:html,filename:getExportFilename('pdf')})})
.then(function(r){if(!r.ok)throw new Error('PDF failed: '+r.status);return r.blob();})
.then(function(blob){var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=getExportFilename('pdf');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);})
.catch(function(e){alert('PDF export failed: '+e.message);})
.finally(function(){btn.disabled=false;btn.innerHTML=orig;});
}
var pdfBtn = document.getElementById('delta-pdf-btn');
if (pdfBtn) pdfBtn.addEventListener('click', function() { doDeltaPdf(pdfBtn); });
var pagePdfBtn = document.getElementById('page-export-pdf-btn');
if (pagePdfBtn) pagePdfBtn.addEventListener('click', function() { doDeltaPdf(pagePdfBtn); });
if (location.protocol === 'file:') {
[pageHtmlBtn, chartsBtn].forEach(function(b) { if (b) { b.disabled=true; b.style.opacity='0.45'; b.style.cursor='not-allowed'; b.title='Already viewing an exported HTML file'; b.textContent='Export HTML'; } });
[pdfBtn, pagePdfBtn].forEach(function(b) { if (b) { b.disabled=true; b.style.opacity='0.45'; b.style.cursor='not-allowed'; b.title='PDF export requires a running server'; b.textContent='Export PDF'; } });
}
var ppSel = document.getElementById('per-page-sel');
if (ppSel) ppSel.addEventListener('change', function() { window.setDeltaPerPage(this.value); });
var pathLink = document.getElementById('project-path-link');
if (pathLink) pathLink.addEventListener('click', function(e) { e.preventDefault(); openFolder(this.dataset.folder); });
})();
// ── Export helpers ────────────────────────────────────────────────────────
function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
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);}
function slocMakeXlsx(fname,sd,dr){
var enc=new TextEncoder();
// CRC-32 table
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;}
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;}
function u2(n){return[n&0xFF,(n>>8)&0xFF];}
function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
// Shared string table
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];}
function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
// Worksheet builder — each WS() call gets its own row counter R
function WS(){
var R=0,buf=[];
function cl(c){return String.fromCharCode(65+c);}
function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
'<v>'+S(v)+'</v></c>';}
function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
(st?' s="'+st+'"':'')+'>'+
'<v>'+(+v)+'</v></c>';}
function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
'<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
'<sheetFormatPr defaultRowHeight="15"/>'+
(cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
return{sc:sc,nc:nc,row:row,xml:xml};
}
// Language breakdown
var lm={};
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;});
var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
// Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
function _sp(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
function _tp(n){var tf=sd.fm+sd.fa+sd.fr+sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
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):'';}
function _ps(p){if(!p)return 0;if(p==='0.0%')return 7;if(p==='new')return 5;return p.charAt(0)==='-'?6:5;}
// Summary sheet
var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
r1(s1(0,'OxideSLOC \u2014 Scan Delta Report',1));
r1(s1(0,proj,2));
r1(s1(0,sd.bts+' \u2192 '+sd.cts,2));
r1('');
r1(s1(0,'Metric',3)+s1(1,_blabel,3)+s1(2,_clabel,3)+s1(3,'Delta',3)+s1(4,'% Change',3));
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))));
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))));
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))));
r1('');
r1(s1(0,'FILE CHANGES',8));
r1(s1(0,'Category',3)+s1(3,'Count',3)+s1(4,'% of Total',3));
r1(s1(0,'Modified')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm,4)+s1(4,_tp(sd.fm)));
r1(s1(0,'Added')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fa,4)+s1(4,_tp(sd.fa)));
r1(s1(0,'Removed')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fr,4)+s1(4,_tp(sd.fr)));
r1(s1(0,'Unchanged')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fu,4)+s1(4,_tp(sd.fu)));
if(langs.length){
r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
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)));});
}
r1('');r1(s1(0,'SCAN METADATA',8));
r1(s1(1,_blabel)+s1(2,_clabel));
r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
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"/>');
// File Delta sheet
var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
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));
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)));});
var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="9" width="13" customWidth="1"/>');
// Shared strings XML
var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
'<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
// XLSX file map
var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
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>',
'_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>',
'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>',
'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>',
'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>',
'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
// ZIP packer — STORED (no compression), compatible with all XLSX readers
var zparts=[],zcds=[],zoff=0,znf=0;
['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
].forEach(function(name){
var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
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]);
var entry=new Uint8Array(lha.length+nb.length+sz);
entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
zparts.push(entry);
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));
var cde=new Uint8Array(cda.length+nb.length);
cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
zcds.push(cde);zoff+=entry.length;znf++;
});
var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
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]);
var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
zout.set(new Uint8Array(ea),zpos);
var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
var xurl=URL.createObjectURL(xblob);
var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
setTimeout(function(){URL.revokeObjectURL(xurl);},200);
}
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;');}
var _exportBase='{{ project_label }}_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}';
function getExportFilename(ext){return _exportBase+'.'+ext;}
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 }}',btests:{{ baseline_test_count }},ctests:{{ current_test_count }},bcov:{% if let Some(p) = baseline_coverage_pct %}{{ p }}{% else %}null{% endif %},ccov:{% if let Some(p) = current_coverage_pct %}{{ p }}{% else %}null{% endif %}};
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;}
var _blabel=_mkScanLabel('Baseline',_sd.btag,_sd.bbr,_sd.bsha);
var _clabel=_mkScanLabel('Current',_sd.ctag,_sd.cbr,_sd.csha);
function _slPct(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
function _tfPct(n){var tf=_sd.fm+_sd.fa+_sd.fr+_sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
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):'';}
var _summaryHdrs = ['Metric',_blabel,_clabel,'Delta','% Change'];
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)]];}
var _dh = ['File','Language','Status','Code Before ('+_blabel+')','Code After ('+_clabel+')','Code Delta','Comment Delta','Total Delta','% Code Chg'];
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;}
window.exportDeltaCsv = function(){slocCsv(_exportBase+'.csv',_dh,getDeltaExportRows());};
window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
// ── Chart HTML report ─────────────────────────────────────────────────────
function slocChartReport(fname, sd, dr) {
var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
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(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}
function px(n){return Math.round(n);}
var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
// Language map
var lm={};
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;});
var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
// Builds onmouse* attrs for interactive tooltip on each SVG element
function barTT(label,val){
return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
}
// ── Chart 1: Baseline vs Current grouped bars ────────────────────────
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'}];
var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
var C1W=600,C1H=188,c1mt=36,c1mb=26,c1ml=14,c1mr=14;
var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=56,c1gap=10;
var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
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"/>';}
c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
c1mets.forEach(function(m,i){
var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
c1+='<text x="'+cx+'" y="16" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="12" font-weight="600" fill="#444">'+esc(m.l)+'</text>';
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))+'/>';
c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+px(c1mt+c1ph-bh0-4)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="9" fill="'+m.bc+'">'+fmt(m.b)+'</text>';
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))+'/>';
c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+px(c1mt+c1ph-bh1-4)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="9" fill="'+m.cc+'">'+fmt(m.c)+'</text>';
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>';
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>';
});
c1+='</svg>';
// ── Chart 2: Delta by Metric ─────────────────────────────────────────
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'}];
var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
var C2W=530,rH=56,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
mets.forEach(function(m,i){
var y=16+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
c2+='<text x="'+(c2LW-8)+'" y="'+(y+20)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="12" font-weight="600" fill="'+m.mc+'">'+esc(m.l)+'</text>';
c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
if(bw>=52){
c2+='<text x="'+px(bx+bw/2)+'" y="'+(y+26)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="12" font-weight="700" fill="white">'+esc(vStr)+'</text>';
}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+26)+'" text-anchor="'+anc2+'" font-family="Inter,Calibri,Arial" font-size="12" font-weight="700" fill="'+col+'">'+esc(vStr)+'</text>';
}
});
c2+='</svg>';
// ── Chart 3: Language Code Delta ─────────────────────────────────────
var c3='';
if(langs.length){
var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
var C3W=550,c3LW=124,c3FW=52;
var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
var L3rH=30,C3H=langs.length*L3rH+20;
c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
langs.forEach(function(l,i){
var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
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':''))+'/>';
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>';
}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>';
}
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>';
});
c3+='</svg>';
}
// ── Chart 4: File Change Donut — centered pie with legend below
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;});
var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
var C4W=240,Ro=75,Ri=48,cx4=120,cy4=88,legY=172,legRowH=18,C4H=legY+Math.ceil(segs.length/2)*legRowH+8;
var c4='<svg viewBox="0 0 '+C4W+' '+C4H+'" width="100%" style="max-width:336px;display:block;margin:0 auto;" xmlns="http://www.w3.org/2000/svg">';
var ang=-Math.PI/2;
segs.forEach(function(s){
var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
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)+'%')+'/>';
ang+=sw;
});
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>';
c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
segs.forEach(function(s,i){
var col=i%2===0?14:C4W/2+6,row=Math.floor(i/2);
c4+='<rect x="'+col+'" y="'+(legY+row*legRowH)+'" width="12" height="12" fill="'+s.c+'" rx="2"/>';
c4+='<text x="'+(col+16)+'" y="'+(legY+row*legRowH+10)+'" font-family="Inter,Calibri,Arial" font-size="11" fill="#555">'+esc(s.l)+': '+fmt(s.v)+'</text>';
});
c4+='</svg>';
// ── Embedded tooltip JS for the downloaded HTML ───────────────────────
var ttJs='var tt=document.getElementById("ox-tt");'+
'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
'function oxMT(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";}'+
'function oxHT(){tt.style.display="none";}';
// body max-width keeps charts from inflating beyond design dimensions on
// wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
// each chart's height blows up proportionally, breaking the one-page layout.
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;}'+
'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
'.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
'.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
'.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
'svg{display:block;}'+
'.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
'#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;}'+
'.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
'<title>OxideSLOC — Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
'<div id="ox-tt"><\/div>'+
'<h1>OxideSLOC — Scan Delta Charts<\/h1>'+
'<p class="sub">'+esc(proj)+' · '+esc(sd.bts)+' → '+esc(sd.cts)+'<\/p>'+
'<div class="two-col">'+
'<div class="card"><h2>Code Metrics — Baseline vs Current<\/h2>'+
'<div class="leg">'+
'<span><span class="dot" style="background:#93C5FD"><\/span><span style="color:#2563EB;font-weight:600">Code Lines<\/span><\/span>'+
'<span><span class="dot" style="background:#C4B5FD"><\/span><span style="color:#7C3AED;font-weight:600">Files<\/span><\/span>'+
'<span><span class="dot" style="background:#6EE7B7"><\/span><span style="color:#0D9488;font-weight:600">Comments<\/span><\/span>'+
'<span style="font-size:10px;color:#888"> (faded = before)<\/span><\/div>'+c1+'<\/div>'+
(langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
'<\/div>'+
'<div class="two-col">'+
'<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
'<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
'<\/div>'+
'<script>'+ttJs+'<\/script>'+
'<\/body><\/html>';
slocDownload(html, fname, 'text/html;charset=utf-8;');
}
window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
window.buildDeltaChartsHtml = function() {
function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
var sd=_sd;
var projEl=document.querySelector('[data-folder]');
var proj=projEl?projEl.getAttribute('data-folder'):'';
var c1h=document.getElementById('ic-c1')?document.getElementById('ic-c1').innerHTML:'';
var c2h=document.getElementById('ic-c2')?document.getElementById('ic-c2').innerHTML:'';
var c3h=document.getElementById('ic-c3')?document.getElementById('ic-c3').innerHTML:'';
var c4h=document.getElementById('ic-c4')?document.getElementById('ic-c4').innerHTML:'';
var ttJs='var tt=document.getElementById("ox-tt");function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}function oxMT(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";}function oxHT(){tt.style.display="none";}';
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;}h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}svg{display:block;}.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}#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;max-width:240px;white-space:nowrap;}.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
return '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>OxideSLOC — Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
'<div id="ox-tt"><\/div>'+
'<h1>OxideSLOC — Scan Delta Charts<\/h1>'+
'<p class="sub">'+esc(proj)+' · '+esc(sd.bts||'')+' → '+esc(sd.cts||'')+'<\/p>'+
'<div class="two-col">'+
'<div class="card"><h2>Code Metrics — Baseline vs Current<\/h2>'+
'<div class="leg"><span><span class="dot" style="background:#93C5FD"><\/span><span style="color:#2563EB;font-weight:600">Code Lines<\/span><\/span>'+
'<span><span class="dot" style="background:#C4B5FD"><\/span><span style="color:#7C3AED;font-weight:600">Files<\/span><\/span>'+
'<span><span class="dot" style="background:#6EE7B7"><\/span><span style="color:#0D9488;font-weight:600">Comments<\/span><\/span><\/div>'+c1h+'<\/div>'+
(c3h?'<div class="card"><h2>Language Code Delta<\/h2>'+c3h+'<\/div>':'<div><\/div>')+
'<\/div>'+
'<div class="two-col">'+
'<div class="card"><h2>Delta by Metric<\/h2>'+c2h+'<\/div>'+
'<div class="card"><h2>File Change Distribution<\/h2>'+c4h+'<\/div>'+
'<\/div>'+
'<script>'+ttJs+'<\/script>'+
'<\/body><\/html>';
};
// ── Inline delta charts ────────────────────────────────────────────────────
var _icTT=document.getElementById('ic-tt');
window.icTT=function(e,t,v){if(!_icTT)return;_icTT.innerHTML='<strong>'+t+'</strong><br>'+v;_icTT.style.display='block';window.icMT(e);};
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';};
window.icHT=function(){if(_icTT)_icTT.style.display='none';};
window.addEventListener('blur',function(){window.icHT();});
document.addEventListener('visibilitychange',function(){if(document.hidden)window.icHT();});
(function(){
var OX='#C45C10',GN='#2A6846',RD='#B23030',GY='#AAAAAA',LGY='#DDDDDD';
function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
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(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}
function px(n){return Math.round(n);}
function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
function btt(l,v){return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}
function addTT(el){if(!el)return;el.addEventListener('mouseover',function(e){var t=e.target.closest('[data-ttl]');if(t){var ttl=t.getAttribute('data-ttl');icTT(e,ttl,t.getAttribute('data-ttv'));el.querySelectorAll('[data-ttl]').forEach(function(x){x.style.filter='';x.style.opacity='';});el.querySelectorAll('[data-ttl]').forEach(function(x){if(x.getAttribute('data-ttl')===ttl)x.style.filter='brightness(1.2)';});}else{icHT();el.querySelectorAll('[data-ttl]').forEach(function(x){x.style.filter='';x.style.opacity='';})}});el.addEventListener('mouseleave',function(){icHT();el.querySelectorAll('[data-ttl]').forEach(function(x){x.style.filter='';x.style.opacity='';});});el.addEventListener('mousemove',function(e){icMT(e);});}
var dr=getDeltaExportRows(),sd=_sd,lm={};
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;});
var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
// Chart 1: Baseline vs Current grouped bars
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'}];
var maxV1=Math.max.apply(null,c1mets.map(function(m){return Math.max(m.b,m.c);}))*1.15||1;
var C1W=600,C1H=188,c1mt=36,c1mb=26,c1ml=14,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=56,c1gap=10;
var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
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"/>';}
c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
c1mets.forEach(function(m,i){
var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
c1+='<text x="'+cx+'" y="16" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="12" font-weight="600" fill="#444">'+esc(m.l)+'</text>';
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"/>';
c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+px(c1mt+c1ph-bh0-4)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="9" fill="'+m.bc+'">'+fmt(m.b)+'</text>';
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"/>';
c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+px(c1mt+c1ph-bh1-4)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="9" fill="'+m.cc+'">'+fmt(m.c)+'</text>';
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>';
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>';
});
c1+='</svg>';
// Chart 2: Delta by Metric
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'}];
var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
var C2W=530,rH=56,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;
var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
mets.forEach(function(m,i){
var y=16+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);
c2+='<text x="'+(c2LW-8)+'" y="'+(y+20)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="12" font-weight="600" fill="'+m.mc+'">'+esc(m.l)+'</text>';
c2+='<rect'+btt(m.l,'Delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"/>';
if(bw>=52){c2+='<text x="'+px(bx+bw/2)+'" y="'+(y+26)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="12" font-weight="700" fill="white">'+esc(vStr)+'</text>';}
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+26)+'" text-anchor="'+anc2+'" font-family="Inter,Calibri,Arial" font-size="12" font-weight="700" fill="'+col+'">'+esc(vStr)+'</text>';}
});
c2+='</svg>';
// Chart 3: Language Code Delta
var c3='';
if(langs.length){
var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
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;
c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
langs.forEach(function(l,i){
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);
c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="11" fill="#444">'+esc(l)+'</text>';
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"/>';
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>';}
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>';}
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>';
});
c3+='</svg>';
}
// Chart 4: File Change Donut — centered pie with legend below
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;});
var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
var C4W=240,Ro=75,Ri=48,cx4=120,cy4=88,legY=172,legRowH=18,C4H=legY+Math.ceil(segs.length/2)*legRowH+8;
var c4='<svg viewBox="0 0 '+C4W+' '+C4H+'" width="100%" style="max-width:336px;display:block;margin:0 auto;" xmlns="http://www.w3.org/2000/svg">',ang=-Math.PI/2;
if(segs.length===1){
c4+='<circle'+btt(segs[0].l,fmt(segs[0].v)+' files • 100%')+' cx="'+cx4+'" cy="'+cy4+'" r="'+Ro+'" fill="'+segs[0].c+'"/>';
c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="var(--surface)"/>';
} else {
segs.forEach(function(s){
var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
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);
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);
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"/>';
ang+=sw;
});
}
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>';
c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="10" fill="#888">total files</text>';
segs.forEach(function(s,i){
var col=i%2===0?14:C4W/2+6,row=Math.floor(i/2);
c4+='<rect'+btt(s.l,fmt(s.v)+' files • '+px(s.v/tot*100)+'%')+' x="'+col+'" y="'+(legY+row*legRowH)+'" width="12" height="12" fill="'+s.c+'" rx="2" style="cursor:pointer;"/>';
c4+='<text'+btt(s.l,fmt(s.v)+' files • '+px(s.v/tot*100)+'%')+' x="'+(col+16)+'" y="'+(legY+row*legRowH+10)+'" font-family="Inter,Calibri,Arial" font-size="11" fill="#555" style="cursor:pointer;">'+esc(s.l)+': '+fmt(s.v)+'</text>';
});
c4+='</svg>';
var e1=document.getElementById('ic-c1');if(e1){e1.innerHTML=c1;addTT(e1);}
var e2=document.getElementById('ic-c2');if(e2){e2.innerHTML=c2;addTT(e2);}
var e3=document.getElementById('ic-c3');if(e3){e3.innerHTML=langs.length?c3:'<p style="color:var(--muted);font-size:13px;padding:8px 0 0;">No language delta.</p>';addTT(e3);}
var e4=document.getElementById('ic-c4');if(e4){e4.innerHTML=c4;addTT(e4);}
var lc=document.getElementById('ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
// Compare Timeline chart (Baseline vs Current, 2 points)
(function() {
var activeCmpMetric='code';
var cmpMetricLabel={code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'};
function renderCmpTL(metric) {
var svg=document.getElementById('cmp-tl-svg');if(!svg)return;
var W=svg.getBoundingClientRect().width||800,H=280;
svg.setAttribute('height',H);
var pad={l:62,r:20,t:32,b:72};
var dark=document.body.classList.contains('dark-theme');
var cmpPts=[
{v:{code:_sd.bc,files:_sd.bf,comments:_sd.bcm,tests:_sd.btests,cov:_sd.bcov},label:(_sd.bsha||'').substring(0,7)||'Base'},
{v:{code:_sd.cc,files:_sd.cf,comments:_sd.ccm,tests:_sd.ctests,cov:_sd.ccov},label:(_sd.csha||'').substring(0,7)||'Curr'}
];
var pts=cmpPts.map(function(p){var v=p.v[metric];return(v==null)?null:Number(v);});
var valid=pts.filter(function(v){return v!=null;});
if(!valid.length){var _nd_dark=document.body.classList.contains('dark-theme');var _nd_bg=_nd_dark?'#241a12':'#fbf7f2';var _nd_tc=_nd_dark?'rgba(255,255,255,0.30)':'rgba(67,52,45,0.32)';var _nd_ts=_nd_dark?'rgba(255,255,255,0.55)':'rgba(67,52,45,0.60)';var _nd_lbl=(cmpMetricLabel[metric]||metric);var _nd_cov=metric==='cov';var _nd_msg=_nd_cov?'No coverage data for these scans':'No '+_nd_lbl.toLowerCase()+' recorded';var _nd_sub=_nd_cov?'Coverage appears once test results are captured during a scan.':'Neither the baseline nor current scan reported a value for this metric.';var _cx=W/2,_cy=H/2;svg.setAttribute('viewBox','0 0 '+W+' '+H);svg.innerHTML='<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+_nd_bg+'" rx="8"/>'+'<g opacity="0.55"><rect x="'+(_cx-28).toFixed(1)+'" y="'+(_cy-50).toFixed(1)+'" width="56" height="34" rx="5" fill="none" stroke="'+_nd_tc+'" stroke-width="1.6"/><polyline points="'+(_cx-20).toFixed(1)+','+(_cy-24).toFixed(1)+' '+(_cx-7).toFixed(1)+','+(_cy-30).toFixed(1)+' '+(_cx+6).toFixed(1)+','+(_cy-26).toFixed(1)+' '+(_cx+20).toFixed(1)+','+(_cy-34).toFixed(1)+'" fill="none" stroke="'+_nd_tc+'" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></g>'+'<text x="'+_cx.toFixed(1)+'" y="'+(_cy+4).toFixed(1)+'" text-anchor="middle" font-size="14" font-weight="700" fill="'+_nd_ts+'">'+_nd_msg+'</text>'+'<text x="'+_cx.toFixed(1)+'" y="'+(_cy+24).toFixed(1)+'" text-anchor="middle" font-size="11.5" fill="'+_nd_tc+'">'+_nd_sub+'</text>';return;}
var minV=Math.min.apply(null,valid),maxV=Math.max.apply(null,valid);
if(minV===maxV){minV=Math.max(0,minV-1);maxV=maxV+1;}
var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
var cx0=pad.l,cx1=pad.l+plotW;
var cy0=pts[0]!=null?pad.t+plotH-(pts[0]-minV)/(maxV-minV)*plotH:pad.t+plotH;
var cy1=pts[1]!=null?pad.t+plotH-(pts[1]-minV)/(maxV-minV)*plotH:pad.t+plotH;
var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
function fmtN(n){var v=Number(n),a=Math.abs(v);if(a>=1e6)return(v/1e6).toFixed(1).replace(/\.0$/,'')+'M';if(a>=1e4)return(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}
function escH(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
var parts=[];
parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
for(var gi=0;gi<5;gi++){
var gy=pad.t+plotH/4*gi,gv=maxV-(maxV-minV)/4*gi;
parts.push('<line x1="'+pad.l+'" y1="'+gy.toFixed(1)+'" x2="'+(W-pad.r)+'" y2="'+gy.toFixed(1)+'" stroke="'+gridColor+'" stroke-width="1"/>');
parts.push('<text x="'+(pad.l-6)+'" y="'+(gy+4).toFixed(1)+'" text-anchor="end" font-size="10" fill="'+textColor+'">'+fmtN(gv)+'</text>');
}
parts.push('<path d="M '+cx0.toFixed(1)+' '+(pad.t+plotH)+' L '+cx0.toFixed(1)+' '+cy0.toFixed(1)+' L '+cx1.toFixed(1)+' '+cy1.toFixed(1)+' L '+cx1.toFixed(1)+' '+(pad.t+plotH)+' Z" fill="'+areaColor+'"/>');
parts.push('<line x1="'+cx0.toFixed(1)+'" y1="'+cy0.toFixed(1)+'" x2="'+cx1.toFixed(1)+'" y2="'+cy1.toFixed(1)+'" stroke="#d37a4c" stroke-width="2.2"/>');
var dotPts=[{cx:cx0,cy:cy0,v:pts[0],lbl:cmpPts[0].label,anchor:'start',lbl2:'BASELINE'},
{cx:cx1,cy:cy1,v:pts[1],lbl:cmpPts[1].label,anchor:'end',lbl2:'CURRENT'}];
dotPts.forEach(function(pt){
parts.push('<text x="'+pt.cx.toFixed(1)+'" y="'+(pt.cy-11).toFixed(1)+'" text-anchor="'+pt.anchor+'" font-size="11" font-weight="600" fill="'+textColor+'">'+fmtN(pt.v)+'</text>');
parts.push('<circle cx="'+pt.cx.toFixed(1)+'" cy="'+pt.cy.toFixed(1)+'" r="5" fill="#d37a4c" stroke="'+(dark?'#241a12':'#fbf7f2')+'" stroke-width="1.5"/>');
parts.push('<text x="'+pt.cx.toFixed(1)+'" y="'+(H-pad.b+18)+'" text-anchor="'+pt.anchor+'" font-size="15" fill="'+textColor+'" font-family="ui-monospace,monospace">'+escH(pt.lbl)+'</text>');
parts.push('<text x="'+pt.cx.toFixed(1)+'" y="'+(H-pad.b+32)+'" text-anchor="'+pt.anchor+'" font-size="9" font-weight="700" fill="'+textColor+'">'+escH(pt.lbl2)+'</text>');
});
parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escH(cmpMetricLabel[metric]||metric)+'</text>');
svg.setAttribute('viewBox','0 0 '+W+' '+H);
svg.innerHTML=parts.join('');
// Hover: crosshair + tooltip (matches multi-scan timeline)
var cmpTT=document.getElementById('ic-tt');
svg.onmousemove=function(e){
var rect=svg.getBoundingClientRect();
var scaleX=W/rect.width;
var mouseX=(e.clientX-rect.left)*scaleX;
var nearest=-1,minDist=Infinity;
var cxArr=[cx0,cx1];
for(var k=0;k<2;k++){if(pts[k]==null)continue;var dx=Math.abs(cxArr[k]-mouseX);if(dx<minDist){minDist=dx;nearest=k;}}
if(nearest<0)return;
var nc=cxArr[nearest],ny=(nearest===0?cy0:cy1);
var xhair=svg.querySelector('.cmp-xhair');
if(!xhair){xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','cmp-xhair');svg.appendChild(xhair);}
xhair.innerHTML='<line x1="'+nc.toFixed(1)+'" y1="'+pad.t+'" x2="'+nc.toFixed(1)+'" y2="'+(pad.t+plotH)+'" stroke="rgba(211,122,76,0.55)" stroke-width="1.5" stroke-dasharray="4,3" pointer-events="none"/>';
if(!cmpTT)return;
var clbl=cmpPts[nearest].label;
var scanLbl=nearest===0?'Baseline':'Current';
cmpTT.innerHTML='<strong>'+scanLbl+'</strong> <span style="font-family:monospace;font-size:11px;opacity:.75">'+escH(clbl)+'</span><br>'+escH(cmpMetricLabel[metric]||metric)+': <strong>'+fmtN(pts[nearest])+'</strong>';
var bx=rect.left+(nc/W*rect.width)+18;
if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
cmpTT.style.left=bx+'px';cmpTT.style.top=(e.clientY-38)+'px';cmpTT.style.display='block';
};
svg.onmouseleave=function(){
var xhair=svg.querySelector('.cmp-xhair');if(xhair)xhair.innerHTML='';
if(cmpTT)cmpTT.style.display='none';
};
}
document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(btn){
btn.addEventListener('click',function(){
activeCmpMetric=this.dataset.cmpMetric;
document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(b){b.classList.remove('active');});
this.classList.add('active');
renderCmpTL(activeCmpMetric);
});
});
var ttgl=document.getElementById('theme-toggle');
if(ttgl)ttgl.addEventListener('click',function(){setTimeout(function(){renderCmpTL(activeCmpMetric);},0);});
if(typeof ResizeObserver!=='undefined'){
var cmpSvg=document.getElementById('cmp-tl-svg');
if(cmpSvg)new ResizeObserver(function(){renderCmpTL(activeCmpMetric);}).observe(cmpSvg);
}
renderCmpTL(activeCmpMetric);
})();
// HTML legend hover -> highlight matching SVG bars within the SAME card only
document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){
var metric=leg.getAttribute('data-highlight');
var parentCard=leg.closest('.ic-card');
var chartEl=parentCard?parentCard.querySelector('[id]'):null;
if(!chartEl)return;
leg.addEventListener('mouseenter',function(){
chartEl.querySelectorAll('[data-ttl]').forEach(function(x){
if(x.getAttribute('data-ttl').indexOf(metric)===0){x.style.filter='brightness(1.35) drop-shadow(0 2px 8px rgba(0,0,0,0.28))';x.style.opacity='1';}
else{x.style.opacity='0.28';}
});
});
leg.addEventListener('mouseleave',function(){
chartEl.querySelectorAll('[data-ttl]').forEach(function(x){x.style.filter='';x.style.opacity='';});
});
});
document.querySelectorAll('.cmp-author-val').forEach(function(el){var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');});
})();
</script>
<script nonce="{{ csp_nonce }}">
(function(){
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'}];
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);});}
try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
function init(){
var btn=document.getElementById('settings-btn');if(!btn)return;
var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
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>';
document.body.appendChild(m);
var g=document.getElementById('scheme-grid');
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);});
var cl=document.getElementById('settings-close');
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);
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');});
if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
}
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
}());
</script>
<script nonce="{{ csp_nonce }}">(function(){var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';
if(location.protocol==='file:'){if(lbl)lbl.textContent='Offline';if(dot){dot.style.background='#888';dot.style.boxShadow='none';}if(pingEl)pingEl.textContent='';if(fm)fm.textContent='oxide-sloc v{{ version }} \u2014 Saved Report';var td=document.querySelector('.server-status-tip');if(td)td.textContent='Saved HTML report \u2014 server not connected.';return;}
if(lbl)lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{{ version }} — Mode: '+(isServer?'Network Server':'Local');function setDot(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}function doPing(){var t0=performance.now();fetch('/healthz',{cache:'no-store'}).then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}).catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});}doPing();setInterval(doPing,5000);})();</script>
</body>
</html>
"##,
ext = "html"
)]
// Template structs need many bool fields to pass Askama rendering flags.
#[allow(clippy::struct_excessive_bools)]
struct CompareTemplate {
version: &'static str,
project_label: String,
baseline_git_commit: String,
current_git_commit: String,
baseline_run_id: String,
current_run_id: String,
baseline_run_id_short: String,
current_run_id_short: String,
baseline_timestamp: String,
baseline_timestamp_utc_ms: i64,
current_timestamp: String,
current_timestamp_utc_ms: i64,
project_path: String,
baseline_code: u64,
current_code: u64,
code_lines_delta_str: String,
code_lines_delta_class: String,
baseline_files: u64,
current_files: u64,
files_analyzed_delta_str: String,
files_analyzed_delta_class: String,
baseline_comments: u64,
current_comments: u64,
comment_lines_delta_str: String,
comment_lines_delta_class: String,
baseline_code_fmt: String,
current_code_fmt: String,
baseline_files_fmt: String,
current_files_fmt: String,
baseline_comments_fmt: String,
current_comments_fmt: String,
code_lines_pct_str: String,
files_analyzed_pct_str: String,
comment_lines_pct_str: String,
code_lines_added: i64,
code_lines_removed: i64,
/// True when baseline had 0 code lines — the scope is entirely new in the current scan.
new_scope: bool,
churn_rate_str: String,
churn_rate_class: String,
scope_flag: bool,
files_added: usize,
files_removed: usize,
files_modified: usize,
files_unchanged: usize,
file_rows: Vec<CompareFileDeltaRow>,
baseline_git_author: Option<String>,
current_git_author: Option<String>,
baseline_git_branch: String,
current_git_branch: String,
baseline_git_tags: Option<String>,
current_git_tags: Option<String>,
baseline_git_commit_date: Option<String>,
current_git_commit_date: Option<String>,
project_name: String,
/// Submodule names present in either run (empty when neither scan used submodule breakdown).
submodule_options: Vec<String>,
/// True when either run has submodule data — controls whether the scope bar is shown.
has_any_submodule_data: bool,
/// The submodule currently being compared, if the `sub` query param was provided.
active_submodule: Option<String>,
/// True when `scope=super` is active — viewing super-repo only (no submodule files).
super_scope_active: bool,
csp_nonce: String,
/// Pre-built HTML for the coverage delta card, or empty string when no coverage data.
coverage_delta_card: String,
baseline_test_count: u64,
current_test_count: u64,
baseline_coverage_pct: Option<f64>,
current_coverage_pct: Option<f64>,
}
// ── LoginTemplate ──────────────────────────────────────────────────────────────
#[derive(Template)]
#[template(
source = r##"
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OxideSLOC | Sign In</title>
<link rel="icon" type="image/png" href="/images/logo/small-logo.png">
<style nonce="{{ csp_nonce }}">
:root {
--bg:#f5efe8; --surface:#fbf7f2; --line:#e6d0bf; --line-strong:#d8bfad;
--text:#2f241c; --muted:#7b675b; --nav:#283790; --nav-2:#013e6b;
--oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 8px 32px rgba(77,44,20,.10);
--err-bg:#fdf0f0; --err-border:#e8b4b4; --err-text:#8b2020;
}
*{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);}
.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);}
.brand{display:flex;align-items:center;gap:12px;text-decoration:none;}
.brand-logo{width:38px;height:42px;object-fit:contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.22));}
.brand-title{color:#fff;font-size:17px;font-weight:800;margin:0;}
.background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
.background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
.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;}
@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));}}
.page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;position:relative;z-index:1;}
.card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:40px;max-width:420px;width:100%;box-shadow:var(--shadow);}
h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
.subtitle{color:var(--muted);font-size:14px;margin:0 0 28px;}
.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;}
label{display:block;font-size:13px;font-weight:700;margin-bottom:6px;}
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;}
input[type=password]:focus{border-color:var(--oxide);}
.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;}
.btn:hover{opacity:.88;}
.hint{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.6;}
code{background:#f3e9e0;padding:1px 5px;border-radius:4px;font-size:11px;}
</style>
</head>
<body>
<div class="background-watermarks" aria-hidden="true">
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
</div>
<div class="code-particles" id="code-particles" aria-hidden="true"></div>
<nav class="top-nav">
<a class="brand" href="/">
<img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
<span class="brand-title">OxideSLOC</span>
</a>
</nav>
<main class="page">
<div class="card">
<h1>Sign In</h1>
<p class="subtitle">Enter the API key printed when the server started.</p>
{% if has_error %}
<div class="error">Incorrect API key — please try again.</div>
{% endif %}
<form method="POST" action="/auth/login">
<input type="hidden" name="next" value="{{ next_url|e }}">
<label for="key">API Key</label>
<input id="key" type="password" name="key" autocomplete="current-password"
placeholder="Paste your API key here" autofocus>
<button type="submit" class="btn">Sign In</button>
</form>
<p class="hint">
The API key was printed in the terminal when the server started.<br>
To skip auth on a trusted LAN: leave <code>SLOC_API_KEY</code> unset.<br>
Note: {{ lockout_threshold }} failed attempts from the same IP triggers a temporary lockout.
</p>
</div>
</main>
<script nonce="{{ csp_nonce }}">
(function() {
(function randomizeWatermarks() {
var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
if (!wms.length) return;
var placed = [];
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;
}
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];
}
var half = Math.floor(wms.length / 2);
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;
});
})();
(function spawnCodeParticles() {
var container = document.getElementById('code-particles');
if (!container) return;
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'
];
var count = 38;
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.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
container.appendChild(el);
})(i);
}
})();
})();
</script>
</body>
</html>
"##,
ext = "html"
)]
pub(crate) struct LoginTemplate {
pub(crate) csp_nonce: String,
pub(crate) has_error: bool,
pub(crate) next_url: String,
pub(crate) lockout_threshold: u32,
}
// ── REST API reference page ────────────────────────────────────────────────────
#[derive(Template)]
#[template(
source = r##"
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OxideSLOC — REST API Reference</title>
<link rel="icon" type="image/png" href="/images/logo/small-logo.png">
<style nonce="{{ csp_nonce }}">
:root {
--radius:14px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
--line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
--nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
--oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
--success:#16a34a;
}
body.dark-theme {
--bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
--text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
}
*{box-sizing:border-box;} html,body{margin:0;min-height:100vh;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);} body{display:flex;flex-direction:column;}
.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);}
.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;}
.brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
.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));}
.brand-copy{display:flex;flex-direction:column;justify-content:center;}
.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;white-space:nowrap;}
.nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
@media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
@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; } }
.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;}
a.nav-pill:hover{background:rgba(255,255,255,0.18);}
.nav-pill.active{background:rgba(255,255,255,0.22);}
.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;}
.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;}
.theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
.theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
.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;}
.settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
.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);}
.settings-close{background:none;border:none;cursor:pointer;padding:4px;color:var(--muted-2);display:flex;align-items:center;border-radius:6px;}
.settings-close svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.5;}
.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;}
.scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
.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;}
.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);}
.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;}
.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;}
.tz-select:focus{border-color:var(--oxide);}
.page{max-width:960px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
.page-header{margin-bottom:28px;}
.page-title{font-size:28px;font-weight:900;letter-spacing:-0.03em;margin:0 0 6px;}
.page-subtitle{font-size:15px;color:var(--muted);line-height:1.6;margin:0;}
.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;}
.callout.key-set{background:rgba(22,163,74,0.10);border:1px solid rgba(22,163,74,0.30);}
.callout.no-key{background:rgba(245,158,11,0.10);border:1px solid rgba(245,158,11,0.30);}
.callout-icon{width:20px;height:20px;flex:0 0 auto;margin-top:1px;}
.callout strong{font-weight:800;}
.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;}
body.dark-theme .callout code{background:rgba(255,255,255,0.10);}
.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;}
.base-url-label{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);flex:0 0 auto;}
.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;}
body.dark-theme .base-url-value{color:var(--accent);}
.section{margin-bottom:36px;}
.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);}
.ep-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:10px;overflow:hidden;}
.ep-header{display:flex;align-items:center;gap:10px;padding:13px 16px;cursor:pointer;user-select:none;flex-wrap:wrap;}
.ep-header:hover{background:var(--surface-2);}
.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;}
.method.get{background:#dcfce7;color:#166534;}
.method.post{background:#dbeafe;color:#1e40af;}
.method.delete{background:#fee2e2;color:#991b1b;}
body.dark-theme .method.get{background:#14532d;color:#86efac;}
body.dark-theme .method.post{background:#1e3a5f;color:#93c5fd;}
body.dark-theme .method.delete{background:#450a0a;color:#fca5a5;}
.ep-path{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:700;flex:1;min-width:0;}
.ep-path .param{color:var(--oxide-2);}
body.dark-theme .ep-path .param{color:var(--oxide);}
.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;}
.auth-badge.protected{background:rgba(239,68,68,0.10);color:#b91c1c;border:1px solid rgba(239,68,68,0.25);}
.auth-badge.public{background:rgba(22,163,74,0.10);color:#166534;border:1px solid rgba(22,163,74,0.25);}
.auth-badge.hmac{background:rgba(245,158,11,0.10);color:#b45309;border:1px solid rgba(245,158,11,0.25);}
body.dark-theme .auth-badge.protected{background:rgba(239,68,68,0.18);color:#fca5a5;border-color:rgba(239,68,68,0.35);}
body.dark-theme .auth-badge.public{background:rgba(22,163,74,0.18);color:#86efac;border-color:rgba(22,163,74,0.35);}
body.dark-theme .auth-badge.hmac{background:rgba(245,158,11,0.18);color:#fcd34d;border-color:rgba(245,158,11,0.35);}
.ep-desc{font-size:13px;color:var(--muted);flex:1;min-width:120px;}
.chevron{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;transition:transform 0.2s ease;flex:0 0 auto;}
.ep-card.open .chevron{transform:rotate(180deg);}
.ep-body{display:none;padding:0 16px 16px;border-top:1px solid var(--line);}
.ep-card.open .ep-body{display:block;}
.ep-desc-full{font-size:14px;color:var(--muted);line-height:1.6;margin:14px 0 14px;}
.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;}
.ep-desc-full a{color:var(--accent-2);text-decoration:none;}
body.dark-theme .ep-desc-full code{background:rgba(255,255,255,0.09);}
.params-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
table.params{width:100%;border-collapse:collapse;margin-bottom:14px;font-size:13px;}
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);}
table.params td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;}
table.params tr:last-child td{border-bottom:none;}
.pt-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;}
.pt-type{color:var(--muted-2);font-size:12px;}
.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;}
.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;}
body.dark-theme .pt-req{background:rgba(239,68,68,0.20);color:#fca5a5;}
body.dark-theme .pt-opt{background:rgba(255,255,255,0.08);color:var(--muted);}
details.schema{margin-bottom:14px;}
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;}
details.schema summary:hover{color:var(--text);}
.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;}
.curl-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
.curl-wrap{position:relative;}
.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;}
.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;}
.curl-copy-btn:hover{background:var(--accent-2);color:#fff;border-color:var(--accent-2);}
.curl-copy-btn.copied{background:var(--success);color:#fff;border-color:var(--success);}
.webhook-note{font-size:14px;color:var(--muted);margin:0 0 14px;line-height:1.6;}
.webhook-note a{color:var(--accent-2);text-decoration:none;}
.background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
.background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
.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;}
@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));}}
.site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
.site-footer a{color:var(--muted);}
</style>
</head>
<body>
<div class="background-watermarks" aria-hidden="true">
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
<img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
</div>
<div class="code-particles" id="code-particles" aria-hidden="true"></div>
<div class="top-nav">
<div class="top-nav-inner">
<a class="brand" href="/">
<img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
<div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">REST API Reference</div></div>
</a>
<div class="nav-right">
<a class="nav-pill" href="/">Home</a>
<div class="nav-dropdown">
<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>
<div class="nav-dropdown-menu">
<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>
</div>
</div>
<a class="nav-pill" href="/compare-scans">Compare Scans</a>
<a class="nav-pill" href="/test-metrics">Test Metrics</a>
<div class="nav-dropdown">
<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>
<div class="nav-dropdown-menu">
<a href="/integrations"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
</div>
</div>
<button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
<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>
</button>
<button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
<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>
<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>
</button>
</div>
</div>
</div>
<div class="page">
<div class="page-header">
<h1 class="page-title">REST API Reference</h1>
<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>
</div>
{% if has_api_key %}
<div class="callout key-set">
<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>
<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>
</div>
{% else %}
<div class="callout no-key">
<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>
<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>
</div>
{% endif %}
<div class="base-url-bar">
<span class="base-url-label">Base URL</span>
<span class="base-url-value" id="base-url">http://127.0.0.1:4317</span>
</div>
<!-- Health -->
<div class="section">
<h2 class="section-title">Health & Status</h2>
<div class="ep-card">
<div class="ep-header">
<span class="method get">GET</span>
<span class="ep-path">/healthz</span>
<span class="auth-badge public">Public</span>
<span class="ep-desc">Server liveness check</span>
<svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="ep-body">
<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>
<p class="params-heading">Response</p>
<div class="schema-block">200 OK
Content-Type: text/plain
ok</div>
<p class="curl-heading">Example</p>
<div class="curl-wrap">
<pre class="curl-block" data-curl-id="c-healthz">curl <span class="base-url-slot">http://127.0.0.1:4317</span>/healthz</pre>
<button class="curl-copy-btn" data-target="c-healthz">Copy</button>
</div>
</div>
</div>
</div>
<!-- Badges -->
<div class="section">
<h2 class="section-title">Badges</h2>
<div class="ep-card">
<div class="ep-header">
<span class="method get">GET</span>
<span class="ep-path">/badge/<span class="param">{metric}</span></span>
<span class="auth-badge public">Public</span>
<span class="ep-desc">SVG badge for README / dashboard embedding</span>
<svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="ep-body">
<p class="ep-desc-full">Returns a shields-style SVG badge showing the requested metric from the most recent scan.</p>
<p class="params-heading">Path Parameters</p>
<table class="params">
<tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
<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>
</table>
<p class="curl-heading">Example</p>
<div class="curl-wrap">
<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>
<button class="curl-copy-btn" data-target="c-badge">Copy</button>
</div>
</div>
</div>
</div>
<!-- Metrics -->
<div class="section">
<h2 class="section-title">Metrics</h2>
<div class="ep-card">
<div class="ep-header">
<span class="method get">GET</span>
<span class="ep-path">/api/metrics/latest</span>
<span class="auth-badge protected">Protected</span>
<span class="ep-desc">Latest scan metrics (JSON)</span>
<svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="ep-body">
<p class="ep-desc-full">Returns detailed metrics for the most recent completed scan, including a summary and per-language breakdown.</p>
<details class="schema"><summary>Response schema</summary>
<div class="schema-block">{
"run_id": string, // UUID
"timestamp": string, // ISO-8601 UTC
"project": string, // scanned root path
"summary": {
"files_analyzed": number,
"files_skipped": number,
"code_lines": number,
"comment_lines": number,
"blank_lines": number,
"total_physical_lines": number,
"functions": number,
"classes": number,
"variables": number,
"imports": number
},
"languages": [
{ "name": string, "files": number, "code_lines": number,
"comment_lines": number, "blank_lines": number,
"functions": number, "classes": number,
"variables": number, "imports": number }
]
}</div></details>
<p class="curl-heading">Example</p>
<div class="curl-wrap">
<pre class="curl-block" data-curl-id="c-metrics-latest">curl -H "Authorization: Bearer $SLOC_API_KEY" \
<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/latest</pre>
<button class="curl-copy-btn" data-target="c-metrics-latest">Copy</button>
</div>
</div>
</div>
<div class="ep-card">
<div class="ep-header">
<span class="method get">GET</span>
<span class="ep-path">/api/metrics/<span class="param">{run_id}</span></span>
<span class="auth-badge protected">Protected</span>
<span class="ep-desc">Metrics for a specific run</span>
<svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="ep-body">
<p class="ep-desc-full">Returns the same shape as <code>/api/metrics/latest</code> but for a specific run identified by UUID.</p>
<p class="params-heading">Path Parameters</p>
<table class="params">
<tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
<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>
</table>
<p class="curl-heading">Example</p>
<div class="curl-wrap">
<pre class="curl-block" data-curl-id="c-metrics-run">curl -H "Authorization: Bearer $SLOC_API_KEY" \
<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/<run_id></pre>
<button class="curl-copy-btn" data-target="c-metrics-run">Copy</button>
</div>
</div>
</div>
<div class="ep-card">
<div class="ep-header">
<span class="method get">GET</span>
<span class="ep-path">/api/metrics/history</span>
<span class="auth-badge protected">Protected</span>
<span class="ep-desc">Paginated scan history</span>
<svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="ep-body">
<p class="ep-desc-full">Returns an array of scan history entries, newest-first. Optionally filtered by root path.</p>
<p class="params-heading">Query Parameters</p>
<table class="params">
<tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
<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>
<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>
</table>
<details class="schema"><summary>Response schema</summary>
<div class="schema-block">[{
"run_id": string,
"timestamp": string, // ISO-8601 UTC
"commit": string | null,
"branch": string | null,
"tags": string[],
"code_lines": number,
"comment_lines": number,
"blank_lines": number,
"physical_lines": number,
"files_analyzed": number,
"project_label": string,
"html_url": string | null
}]</div></details>
<p class="curl-heading">Example</p>
<div class="curl-wrap">
<pre class="curl-block" data-curl-id="c-metrics-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
"<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/history?limit=10"</pre>
<button class="curl-copy-btn" data-target="c-metrics-history">Copy</button>
</div>
</div>
</div>
<div class="ep-card">
<div class="ep-header">
<span class="method get">GET</span>
<span class="ep-path">/api/project-history</span>
<span class="auth-badge protected">Protected</span>
<span class="ep-desc">Project-level scan summary</span>
<svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="ep-body">
<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>
<p class="params-heading">Query Parameters</p>
<table class="params">
<tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
<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>
</table>
<details class="schema"><summary>Response schema</summary>
<div class="schema-block">{
"scan_count": number,
"last_scan_id": string | null,
"last_scan_timestamp": string | null, // ISO-8601
"last_scan_code_lines": number | null,
"last_git_branch": string | null,
"last_git_commit": string | null
}</div></details>
<p class="curl-heading">Example</p>
<div class="curl-wrap">
<pre class="curl-block" data-curl-id="c-proj-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
<span class="base-url-slot">http://127.0.0.1:4317</span>/api/project-history</pre>
<button class="curl-copy-btn" data-target="c-proj-history">Copy</button>
</div>
</div>
</div>
<div class="ep-card">
<div class="ep-header">
<span class="method get">GET</span>
<span class="ep-path">/api/metrics/submodules</span>
<span class="auth-badge protected">Protected</span>
<span class="ep-desc">List known git submodules across scans</span>
<svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="ep-body">
<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>
<p class="params-heading">Query Parameters</p>
<table class="params">
<tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
<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>
</table>
<details class="schema"><summary>Response schema</summary>
<div class="schema-block">[{
"name": string, // submodule name
"relative_path": string // path relative to the project root
}]</div></details>
<p class="curl-heading">Example</p>
<div class="curl-wrap">
<pre class="curl-block" data-curl-id="c-metrics-submodules">curl -H "Authorization: Bearer $SLOC_API_KEY" \
"<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/submodules?root=/path/to/repo"</pre>
<button class="curl-copy-btn" data-target="c-metrics-submodules">Copy</button>
</div>
</div>
</div>
</div>
<!-- Async Run Status -->
<div class="section">
<h2 class="section-title">Async Run Status</h2>
<div class="ep-card">
<div class="ep-header">
<span class="method get">GET</span>
<span class="ep-path">/api/runs/<span class="param">{run_id}</span>/status</span>
<span class="auth-badge protected">Protected</span>
<span class="ep-desc">Poll scan completion</span>
<svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="ep-body">
<p class="ep-desc-full">Poll after submitting a scan. The <code>state</code> field discriminates the response shape.</p>
<details class="schema"><summary>Response schema</summary>
<div class="schema-block">// Running
{ "state": "running", "elapsed_secs": number }
// Complete
{ "state": "complete", "run_id": string }
// Failed
{ "state": "failed", "message": string }</div></details>
<p class="curl-heading">Example</p>
<div class="curl-wrap">
<pre class="curl-block" data-curl-id="c-run-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
<span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/status</pre>
<button class="curl-copy-btn" data-target="c-run-status">Copy</button>
</div>
</div>
</div>
<div class="ep-card">
<div class="ep-header">
<span class="method get">GET</span>
<span class="ep-path">/api/runs/<span class="param">{run_id}</span>/pdf-status</span>
<span class="auth-badge protected">Protected</span>
<span class="ep-desc">Poll PDF generation readiness</span>
<svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="ep-body">
<p class="ep-desc-full">Returns whether the PDF artifact for a completed run is ready for download.</p>
<details class="schema"><summary>Response schema</summary>
<div class="schema-block">{ "ready": boolean, "url": string | null }</div></details>
<p class="curl-heading">Example</p>
<div class="curl-wrap">
<pre class="curl-block" data-curl-id="c-pdf-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
<span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/pdf-status</pre>
<button class="curl-copy-btn" data-target="c-pdf-status">Copy</button>
</div>
</div>
</div>
<div class="ep-card">
<div class="ep-header">
<span class="method post">POST</span>
<span class="ep-path">/api/runs/<span class="param">{run_id}</span>/cancel</span>
<span class="auth-badge protected">Protected</span>
<span class="ep-desc">Cancel a running scan</span>
<svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="ep-body">
<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>
<p class="curl-heading">Example</p>
<div class="curl-wrap">
<pre class="curl-block" data-curl-id="c-run-cancel">curl -X POST \
-H "Authorization: Bearer $SLOC_API_KEY" \
<span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/cancel</pre>
<button class="curl-copy-btn" data-target="c-run-cancel">Copy</button>
</div>
</div>
</div>
</div>
<!-- Run Management -->
<div class="section">
<h2 class="section-title">Run Management</h2>
<div class="ep-card">
<div class="ep-header">
<span class="method get">GET</span>
<span class="ep-path">/api/runs/<span class="param">{run_id}</span>/bundle</span>
<span class="auth-badge protected">Protected</span>
<span class="ep-desc">Download all artifacts for a run as a ZIP archive</span>
<svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="ep-body">
<p class="ep-desc-full">Returns a <code>.zip</code> archive containing every artifact stored for the run: HTML report, PDF, JSON result, CSV, Excel workbook, and scan config TOML. Useful for offline archiving or migration.</p>
<p class="params-heading">Path Parameters</p>
<table class="params">
<tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
<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>
</table>
<details class="schema"><summary>Response</summary>
<div class="schema-block">200 OK — Content-Type: application/zip
Content-Disposition: attachment; filename="sloc-run-<run_id>.zip"
404 Not Found — { "error": string } (run not found or no artifacts)</div></details>
<p class="curl-heading">Example</p>
<div class="curl-wrap">
<pre class="curl-block" data-curl-id="c-run-bundle">curl -H "Authorization: Bearer $SLOC_API_KEY" \
-o run.zip \
<span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/bundle</pre>
<button class="curl-copy-btn" data-target="c-run-bundle">Copy</button>
</div>
</div>
</div>
<div class="ep-card">
<div class="ep-header">
<span class="method delete">DELETE</span>
<span class="ep-path">/api/runs/<span class="param">{run_id}</span></span>
<span class="auth-badge protected">Protected</span>
<span class="ep-desc">Permanently delete a run and all its artifacts</span>
<svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="ep-body">
<p class="ep-desc-full">Removes all on-disk artifacts for the run (HTML, PDF, JSON, CSV, Excel, scan config), purges the entry from the in-memory cache, and removes it from the persisted scan registry. <strong>This action is irreversible.</strong></p>
<p class="params-heading">Path Parameters</p>
<table class="params">
<tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
<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 to delete</td></tr>
</table>
<details class="schema"><summary>Response</summary>
<div class="schema-block">204 No Content — run successfully deleted
500 Internal Server Error — { "error": string } (filesystem deletion failed)</div></details>
<p class="curl-heading">Example</p>
<div class="curl-wrap">
<pre class="curl-block" data-curl-id="c-run-delete">curl -X DELETE \
-H "Authorization: Bearer $SLOC_API_KEY" \
<span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id></pre>
<button class="curl-copy-btn" data-target="c-run-delete">Copy</button>
</div>
</div>
</div>
<div class="ep-card">
<div class="ep-header">
<span class="method post">POST</span>
<span class="ep-path">/api/runs/cleanup</span>
<span class="auth-badge protected">Protected</span>
<span class="ep-desc">Bulk delete runs older than N days</span>
<svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="ep-body">
<p class="ep-desc-full">One-shot age-based cleanup. Deletes all on-disk artifacts and registry entries for runs whose timestamp is older than <code>older_than_days</code> days. For automated recurring cleanup, use the Retention Policy endpoints instead.</p>
<p class="params-heading">Request Body (application/json)</p>
<table class="params">
<tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
<tr><td class="pt-name">older_than_days</td><td class="pt-type">integer</td><td><span class="pt-opt">optional</span></td><td>Delete runs older than this many days. Default: <code>30</code>. Minimum: <code>1</code>.</td></tr>
</table>
<details class="schema"><summary>Response schema</summary>
<div class="schema-block">{ "deleted": number } // count of runs removed</div></details>
<p class="curl-heading">Example — delete runs older than 60 days</p>
<div class="curl-wrap">
<pre class="curl-block" data-curl-id="c-runs-cleanup">curl -X POST \
-H "Authorization: Bearer $SLOC_API_KEY" \
-H "Content-Type: application/json" \
-d '{"older_than_days":60}' \
<span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/cleanup</pre>
<button class="curl-copy-btn" data-target="c-runs-cleanup">Copy</button>
</div>
</div>
</div>
</div>
<!-- Retention Policy -->
<div class="section">
<h2 class="section-title">Retention Policy</h2>
<div class="ep-card">
<div class="ep-header">
<span class="method get">GET</span>
<span class="ep-path">/api/cleanup-policy</span>
<span class="auth-badge protected">Protected</span>
<span class="ep-desc">Get the current retention policy and last-run metadata</span>
<svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="ep-body">
<p class="ep-desc-full">Returns the configured auto-cleanup policy (if any) together with the timestamp and count from the last background cleanup pass. Useful for monitoring whether the policy is running as expected.</p>
<details class="schema"><summary>Response schema</summary>
<div class="schema-block">{
"policy": {
"enabled": boolean,
"max_age_days": number | null, // delete runs older than N days
"max_run_count": number | null, // keep only the N most recent runs
"interval_hours": number // hours between background passes
} | null,
"last_run_at": string | null, // ISO-8601 UTC timestamp
"last_run_deleted": number | null // runs deleted in last pass
}</div></details>
<p class="curl-heading">Example</p>
<div class="curl-wrap">
<pre class="curl-block" data-curl-id="c-policy-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
<span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
<button class="curl-copy-btn" data-target="c-policy-get">Copy</button>
</div>
</div>
</div>
<div class="ep-card">
<div class="ep-header">
<span class="method post">POST</span>
<span class="ep-path">/api/cleanup-policy</span>
<span class="auth-badge protected">Protected</span>
<span class="ep-desc">Save or update the retention policy</span>
<svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="ep-body">
<p class="ep-desc-full">Persists a new retention policy to <code>cleanup_policy.json</code>. If <code>enabled</code> is <code>true</code>, the existing background task is stopped and a new one is started at the given interval. Both rules apply when set — a run is deleted if it exceeds the age limit <em>or</em> falls outside the count limit.</p>
<p class="params-heading">Request Body (application/json)</p>
<table class="params">
<tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
<tr><td class="pt-name">enabled</td><td class="pt-type">boolean</td><td><span class="pt-req">required</span></td><td>Whether to activate the background cleanup task</td></tr>
<tr><td class="pt-name">max_age_days</td><td class="pt-type">integer | null</td><td><span class="pt-opt">optional</span></td><td>Delete runs older than N days. Omit or <code>null</code> to disable age-based cleanup.</td></tr>
<tr><td class="pt-name">max_run_count</td><td class="pt-type">integer | null</td><td><span class="pt-opt">optional</span></td><td>Keep only the N most recent runs. Omit or <code>null</code> to disable count-based cleanup.</td></tr>
<tr><td class="pt-name">interval_hours</td><td class="pt-type">integer</td><td><span class="pt-req">required</span></td><td>Hours between background cleanup passes. Minimum: <code>1</code>.</td></tr>
</table>
<details class="schema"><summary>Response</summary>
<div class="schema-block">204 No Content — policy saved and task (re)started
500 Internal Server Error — { "error": string }</div></details>
<p class="curl-heading">Example — keep 30 days, max 100 runs, check daily</p>
<div class="curl-wrap">
<pre class="curl-block" data-curl-id="c-policy-post">curl -X POST \
-H "Authorization: Bearer $SLOC_API_KEY" \
-H "Content-Type: application/json" \
-d '{"enabled":true,"max_age_days":30,"max_run_count":100,"interval_hours":24}' \
<span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
<button class="curl-copy-btn" data-target="c-policy-post">Copy</button>
</div>
</div>
</div>
<div class="ep-card">
<div class="ep-header">
<span class="method post">POST</span>
<span class="ep-path">/api/cleanup-policy/run-now</span>
<span class="auth-badge protected">Protected</span>
<span class="ep-desc">Trigger an immediate cleanup pass</span>
<svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="ep-body">
<p class="ep-desc-full">Executes the configured retention policy immediately, outside of the normal background schedule. Returns the number of runs deleted. The policy must already be saved (via <code>POST /api/cleanup-policy</code>) before calling this endpoint, but does not need to be enabled.</p>
<details class="schema"><summary>Response schema</summary>
<div class="schema-block">{ "deleted": number } // count of runs removed in this pass</div></details>
<p class="curl-heading">Example</p>
<div class="curl-wrap">
<pre class="curl-block" data-curl-id="c-policy-run-now">curl -X POST \
-H "Authorization: Bearer $SLOC_API_KEY" \
<span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy/run-now</pre>
<button class="curl-copy-btn" data-target="c-policy-run-now">Copy</button>
</div>
</div>
</div>
<div class="ep-card">
<div class="ep-header">
<span class="method delete">DELETE</span>
<span class="ep-path">/api/cleanup-policy</span>
<span class="auth-badge protected">Protected</span>
<span class="ep-desc">Remove the retention policy and stop the background task</span>
<svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="ep-body">
<p class="ep-desc-full">Clears the saved retention policy and stops the background cleanup task if it is running. Does not delete any existing scan runs.</p>
<details class="schema"><summary>Response</summary>
<div class="schema-block">204 No Content — policy removed and task stopped</div></details>
<p class="curl-heading">Example</p>
<div class="curl-wrap">
<pre class="curl-block" data-curl-id="c-policy-delete">curl -X DELETE \
-H "Authorization: Bearer $SLOC_API_KEY" \
<span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
<button class="curl-copy-btn" data-target="c-policy-delete">Copy</button>
</div>
</div>
</div>
</div>
<!-- Scan Profiles -->
<div class="section">
<h2 class="section-title">Scan Profiles</h2>
<div class="ep-card">
<div class="ep-header">
<span class="method get">GET</span>
<span class="ep-path">/api/scan-profiles</span>
<span class="auth-badge protected">Protected</span>
<span class="ep-desc">List saved scan profiles</span>
<svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="ep-body">
<p class="ep-desc-full">Returns all saved scan profiles. Profiles store scan parameters that can be pre-loaded into the scan form.</p>
<details class="schema"><summary>Response schema</summary>
<div class="schema-block">{
"profiles": [{
"id": string, // UUID
"name": string,
"created_at": string, // ISO-8601
"params": object
}]
}</div></details>
<p class="curl-heading">Example</p>
<div class="curl-wrap">
<pre class="curl-block" data-curl-id="c-profiles-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
<span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
<button class="curl-copy-btn" data-target="c-profiles-list">Copy</button>
</div>
</div>
</div>
<div class="ep-card">
<div class="ep-header">
<span class="method post">POST</span>
<span class="ep-path">/api/scan-profiles</span>
<span class="auth-badge protected">Protected</span>
<span class="ep-desc">Save a scan profile</span>
<svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="ep-body">
<p class="ep-desc-full">Creates a named scan profile. The <code>params</code> field accepts any JSON object containing scan settings.</p>
<p class="params-heading">Request Body (application/json)</p>
<table class="params">
<tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
<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>
<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>
</table>
<details class="schema"><summary>Response schema</summary>
<div class="schema-block">{ "ok": true }</div></details>
<p class="curl-heading">Example</p>
<div class="curl-wrap">
<pre class="curl-block" data-curl-id="c-profiles-save">curl -X POST \
-H "Authorization: Bearer $SLOC_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"My Profile","params":{"path":"/my/repo"}}' \
<span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
<button class="curl-copy-btn" data-target="c-profiles-save">Copy</button>
</div>
</div>
</div>
<div class="ep-card">
<div class="ep-header">
<span class="method delete">DELETE</span>
<span class="ep-path">/api/scan-profiles/<span class="param">{id}</span></span>
<span class="auth-badge protected">Protected</span>
<span class="ep-desc">Delete a scan profile</span>
<svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="ep-body">
<p class="ep-desc-full">Permanently deletes a scan profile by its UUID.</p>
<p class="params-heading">Path Parameters</p>
<table class="params">
<tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
<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>
</table>
<details class="schema"><summary>Response schema</summary>
<div class="schema-block">{ "ok": true }</div></details>
<p class="curl-heading">Example</p>
<div class="curl-wrap">
<pre class="curl-block" data-curl-id="c-profiles-del">curl -X DELETE \
-H "Authorization: Bearer $SLOC_API_KEY" \
<span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles/<id></pre>
<button class="curl-copy-btn" data-target="c-profiles-del">Copy</button>
</div>
</div>
</div>
</div>
<!-- Scheduled Scans -->
<div class="section">
<h2 class="section-title">Scheduled Scans</h2>
<div class="ep-card">
<div class="ep-header">
<span class="method get">GET</span>
<span class="ep-path">/api/schedules</span>
<span class="auth-badge protected">Protected</span>
<span class="ep-desc">List configured schedules</span>
<svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="ep-body">
<p class="ep-desc-full">Returns all configured scheduled scans. See <a href="/integrations">Integrations</a> for the full schedule object schema.</p>
<p class="curl-heading">Example</p>
<div class="curl-wrap">
<pre class="curl-block" data-curl-id="c-sched-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
<span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
<button class="curl-copy-btn" data-target="c-sched-list">Copy</button>
</div>
</div>
</div>
<div class="ep-card">
<div class="ep-header">
<span class="method post">POST</span>
<span class="ep-path">/api/schedules</span>
<span class="auth-badge protected">Protected</span>
<span class="ep-desc">Create a schedule</span>
<svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="ep-body">
<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>
<p class="curl-heading">Example</p>
<div class="curl-wrap">
<pre class="curl-block" data-curl-id="c-sched-create">curl -X POST \
-H "Authorization: Bearer $SLOC_API_KEY" \
-H "Content-Type: application/json" \
-d '{"label":"nightly","repo_url":"https://github.com/org/repo","cron":"0 2 * * *"}' \
<span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
<button class="curl-copy-btn" data-target="c-sched-create">Copy</button>
</div>
</div>
</div>
<div class="ep-card">
<div class="ep-header">
<span class="method delete">DELETE</span>
<span class="ep-path">/api/schedules</span>
<span class="auth-badge protected">Protected</span>
<span class="ep-desc">Delete a schedule</span>
<svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="ep-body">
<p class="ep-desc-full">Removes a scheduled scan by its ID.</p>
<p class="curl-heading">Example</p>
<div class="curl-wrap">
<pre class="curl-block" data-curl-id="c-sched-del">curl -X DELETE \
-H "Authorization: Bearer $SLOC_API_KEY" \
-H "Content-Type: application/json" \
-d '{"id":"<schedule_id>"}' \
<span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
<button class="curl-copy-btn" data-target="c-sched-del">Copy</button>
</div>
</div>
</div>
</div>
<!-- Git Browser -->
<div class="section">
<h2 class="section-title">Git Browser</h2>
<div class="ep-card">
<div class="ep-header">
<span class="method get">GET</span>
<span class="ep-path">/api/git/refs</span>
<span class="auth-badge protected">Protected</span>
<span class="ep-desc">List git refs for a repository</span>
<svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="ep-body">
<p class="ep-desc-full">Returns all branches and tags for a local git repository.</p>
<p class="params-heading">Query Parameters</p>
<table class="params">
<tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
<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>
</table>
<p class="curl-heading">Example</p>
<div class="curl-wrap">
<pre class="curl-block" data-curl-id="c-git-refs">curl -H "Authorization: Bearer $SLOC_API_KEY" \
"<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/refs?path=/path/to/repo"</pre>
<button class="curl-copy-btn" data-target="c-git-refs">Copy</button>
</div>
</div>
</div>
<div class="ep-card">
<div class="ep-header">
<span class="method get">GET</span>
<span class="ep-path">/api/git/scan-ref</span>
<span class="auth-badge protected">Protected</span>
<span class="ep-desc">SLOC-scan a specific git ref</span>
<svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="ep-body">
<p class="ep-desc-full">Checks out a specific commit, branch, or tag and runs an SLOC analysis against it.</p>
<p class="params-heading">Query Parameters</p>
<table class="params">
<tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
<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>
<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>
</table>
<p class="curl-heading">Example</p>
<div class="curl-wrap">
<pre class="curl-block" data-curl-id="c-git-scan">curl -H "Authorization: Bearer $SLOC_API_KEY" \
"<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/scan-ref?path=/path/to/repo&ref=main"</pre>
<button class="curl-copy-btn" data-target="c-git-scan">Copy</button>
</div>
</div>
</div>
<div class="ep-card">
<div class="ep-header">
<span class="method get">GET</span>
<span class="ep-path">/api/git/compare-refs</span>
<span class="auth-badge protected">Protected</span>
<span class="ep-desc">Compare SLOC across two git refs</span>
<svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="ep-body">
<p class="ep-desc-full">Runs SLOC analysis on two refs and returns the delta between them.</p>
<p class="params-heading">Query Parameters</p>
<table class="params">
<tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
<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>
<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>
<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>
</table>
<p class="curl-heading">Example</p>
<div class="curl-wrap">
<pre class="curl-block" data-curl-id="c-git-compare">curl -H "Authorization: Bearer $SLOC_API_KEY" \
"<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>
<button class="curl-copy-btn" data-target="c-git-compare">Copy</button>
</div>
</div>
</div>
</div>
<!-- Webhooks -->
<div class="section">
<h2 class="section-title">Webhooks</h2>
<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>
<div class="ep-card">
<div class="ep-header">
<span class="method post">POST</span>
<span class="ep-path">/webhooks/github</span>
<span class="auth-badge hmac">HMAC</span>
<span class="ep-desc">GitHub push event receiver</span>
<svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="ep-body">
<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>
<p class="params-heading">Required Headers</p>
<table class="params">
<tr><th>Header</th><th>Value</th></tr>
<tr><td class="pt-name">X-Hub-Signature-256</td><td>HMAC-SHA256 of the raw body using the per-schedule secret</td></tr>
<tr><td class="pt-name">X-GitHub-Event</td><td><code>push</code></td></tr>
<tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
</table>
</div>
</div>
<div class="ep-card">
<div class="ep-header">
<span class="method post">POST</span>
<span class="ep-path">/webhooks/gitlab</span>
<span class="auth-badge hmac">HMAC</span>
<span class="ep-desc">GitLab push event receiver</span>
<svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="ep-body">
<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>
<p class="params-heading">Required Headers</p>
<table class="params">
<tr><th>Header</th><th>Value</th></tr>
<tr><td class="pt-name">X-Gitlab-Token</td><td>Per-schedule webhook secret</td></tr>
<tr><td class="pt-name">X-Gitlab-Event</td><td><code>Push Hook</code></td></tr>
<tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
</table>
</div>
</div>
<div class="ep-card">
<div class="ep-header">
<span class="method post">POST</span>
<span class="ep-path">/webhooks/bitbucket</span>
<span class="auth-badge hmac">HMAC</span>
<span class="ep-desc">Bitbucket push event receiver</span>
<svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="ep-body">
<p class="ep-desc-full">Receives Bitbucket push events. Authenticated via <code>X-Hub-Signature</code> HMAC-SHA256.</p>
<p class="params-heading">Required Headers</p>
<table class="params">
<tr><th>Header</th><th>Value</th></tr>
<tr><td class="pt-name">X-Hub-Signature</td><td>HMAC-SHA256 of the raw body</td></tr>
<tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
</table>
</div>
</div>
</div>
<!-- Config -->
<div class="section">
<h2 class="section-title">Config Import / Export</h2>
<div class="ep-card">
<div class="ep-header">
<span class="method get">GET</span>
<span class="ep-path">/export-config</span>
<span class="auth-badge protected">Protected</span>
<span class="ep-desc">Export server configuration as JSON</span>
<svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="ep-body">
<p class="ep-desc-full">Returns the current server configuration as a downloadable JSON file.</p>
<p class="curl-heading">Example</p>
<div class="curl-wrap">
<pre class="curl-block" data-curl-id="c-export">curl -H "Authorization: Bearer $SLOC_API_KEY" \
-o config.json \
<span class="base-url-slot">http://127.0.0.1:4317</span>/export-config</pre>
<button class="curl-copy-btn" data-target="c-export">Copy</button>
</div>
</div>
</div>
<div class="ep-card">
<div class="ep-header">
<span class="method post">POST</span>
<span class="ep-path">/import-config</span>
<span class="auth-badge protected">Protected</span>
<span class="ep-desc">Import server configuration</span>
<svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="ep-body">
<p class="ep-desc-full">Imports a previously exported configuration JSON, replacing the active server configuration.</p>
<p class="curl-heading">Example</p>
<div class="curl-wrap">
<pre class="curl-block" data-curl-id="c-import">curl -X POST \
-H "Authorization: Bearer $SLOC_API_KEY" \
-H "Content-Type: application/json" \
-d @config.json \
<span class="base-url-slot">http://127.0.0.1:4317</span>/import-config</pre>
<button class="curl-copy-btn" data-target="c-import">Copy</button>
</div>
</div>
</div>
</div>
<!-- CI Ingest -->
<div class="section">
<h2 class="section-title">CI Ingest</h2>
<div class="ep-card">
<div class="ep-header">
<span class="method post">POST</span>
<span class="ep-path">/api/ingest</span>
<span class="auth-badge protected">Protected</span>
<span class="ep-desc">Push a pre-computed scan result from CI</span>
<svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="ep-body">
<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>
<p class="params-heading">Query Parameters</p>
<table class="params">
<tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
<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>
</table>
<p class="params-heading">Request Body (application/json)</p>
<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>
<details class="schema"><summary>Response schema</summary>
<div class="schema-block">// 201 Created
{
"run_id": string, // UUID of the ingested run
"view_url": string // relative URL to the report page
}</div></details>
<p class="curl-heading">Example</p>
<div class="curl-wrap">
<pre class="curl-block" data-curl-id="c-ingest">curl -X POST \
-H "Authorization: Bearer $SLOC_API_KEY" \
-H "Content-Type: application/json" \
-d @result.json \
"<span class="base-url-slot">http://127.0.0.1:4317</span>/api/ingest?label=my-project"</pre>
<button class="curl-copy-btn" data-target="c-ingest">Copy</button>
</div>
</div>
</div>
</div>
<!-- Artifact Download -->
<div class="section">
<h2 class="section-title">Artifact Download</h2>
<div class="ep-card">
<div class="ep-header">
<span class="method get">GET</span>
<span class="ep-path">/runs/<span class="param">{artifact}</span>/<span class="param">{run_id}</span></span>
<span class="auth-badge protected">Protected</span>
<span class="ep-desc">Download or view a scan artifact</span>
<svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="ep-body">
<p class="ep-desc-full">Serves a stored artifact for a completed run. The <code>artifact</code> segment selects which file to return.</p>
<p class="params-heading">Path Parameters</p>
<table class="params">
<tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
<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>
<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>
</table>
<p class="params-heading">Query Parameters</p>
<table class="params">
<tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
<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>
</table>
<p class="curl-heading">Example — download JSON result</p>
<div class="curl-wrap">
<pre class="curl-block" data-curl-id="c-artifact-json">curl -H "Authorization: Bearer $SLOC_API_KEY" \
-o result.json \
"<span class="base-url-slot">http://127.0.0.1:4317</span>/runs/json/<run_id>?download=1"</pre>
<button class="curl-copy-btn" data-target="c-artifact-json">Copy</button>
</div>
</div>
</div>
</div>
<!-- Embed Widget -->
<div class="section">
<h2 class="section-title">Embed Widget</h2>
<div class="ep-card">
<div class="ep-header">
<span class="method get">GET</span>
<span class="ep-path">/embed/summary</span>
<span class="auth-badge protected">Protected</span>
<span class="ep-desc">Embeddable scan summary widget (iframe)</span>
<svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="ep-body">
<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>
<p class="params-heading">Query Parameters</p>
<table class="params">
<tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
<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>
<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>
</table>
<p class="curl-heading">Example</p>
<div class="curl-wrap">
<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"
width="460" height="260" style="border:none"></iframe></pre>
<button class="curl-copy-btn" data-target="c-embed">Copy</button>
</div>
</div>
</div>
</div>
<!-- Confluence Integration -->
<div class="section">
<h2 class="section-title">Confluence Integration</h2>
<div class="ep-card">
<div class="ep-header">
<span class="method get">GET</span>
<span class="ep-path">/api/confluence/config</span>
<span class="auth-badge protected">Protected</span>
<span class="ep-desc">Get current Confluence configuration</span>
<svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="ep-body">
<p class="ep-desc-full">Returns the active Confluence integration settings. The API token / password is never returned — only whether one is set.</p>
<details class="schema"><summary>Response schema</summary>
<div class="schema-block">{
"configured": boolean,
"tier": "cloud" | "server",
"base_url": string,
"username": string,
"api_token_set": boolean,
"space_key": string,
"parent_page_id": string | null,
"schedule_auto_post": { "<schedule_id>": boolean }
}</div></details>
<p class="curl-heading">Example</p>
<div class="curl-wrap">
<pre class="curl-block" data-curl-id="c-cf-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
<button class="curl-copy-btn" data-target="c-cf-get">Copy</button>
</div>
</div>
</div>
<div class="ep-card">
<div class="ep-header">
<span class="method post">POST</span>
<span class="ep-path">/api/confluence/config</span>
<span class="auth-badge protected">Protected</span>
<span class="ep-desc">Save Confluence configuration</span>
<svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="ep-body">
<p class="ep-desc-full">Persists the Confluence connection settings. Omit <code>credential</code> to keep the existing token.</p>
<p class="params-heading">Request Body (application/json)</p>
<table class="params">
<tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
</table>
<details class="schema"><summary>Response schema</summary>
<div class="schema-block">{ "ok": true }</div></details>
<p class="curl-heading">Example</p>
<div class="curl-wrap">
<pre class="curl-block" data-curl-id="c-cf-save">curl -X POST \
-H "Authorization: Bearer $SLOC_API_KEY" \
-H "Content-Type: application/json" \
-d '{"base_url":"https://myorg.atlassian.net","username":"me@example.com","credential":"my-token","space_key":"ENG"}' \
<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
<button class="curl-copy-btn" data-target="c-cf-save">Copy</button>
</div>
</div>
</div>
<div class="ep-card">
<div class="ep-header">
<span class="method post">POST</span>
<span class="ep-path">/api/confluence/test</span>
<span class="auth-badge protected">Protected</span>
<span class="ep-desc">Test Confluence connection</span>
<svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="ep-body">
<p class="ep-desc-full">Verifies that the saved credentials can connect to and authenticate with Confluence. No request body required.</p>
<details class="schema"><summary>Response schema</summary>
<div class="schema-block">{ "ok": boolean, "error": string | undefined }</div></details>
<p class="curl-heading">Example</p>
<div class="curl-wrap">
<pre class="curl-block" data-curl-id="c-cf-test">curl -X POST \
-H "Authorization: Bearer $SLOC_API_KEY" \
<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/test</pre>
<button class="curl-copy-btn" data-target="c-cf-test">Copy</button>
</div>
</div>
</div>
<div class="ep-card">
<div class="ep-header">
<span class="method post">POST</span>
<span class="ep-path">/api/confluence/post</span>
<span class="auth-badge protected">Protected</span>
<span class="ep-desc">Publish a scan report to Confluence</span>
<svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="ep-body">
<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>
<p class="params-heading">Request Body (application/json)</p>
<table class="params">
<tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
<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>
<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>
<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>
</table>
<details class="schema"><summary>Response schema</summary>
<div class="schema-block">// 200 OK
{ "ok": true, "page_id": string }
// 400 / 502 on error
{ "ok": false, "error": string }</div></details>
<p class="curl-heading">Example</p>
<div class="curl-wrap">
<pre class="curl-block" data-curl-id="c-cf-post">curl -X POST \
-H "Authorization: Bearer $SLOC_API_KEY" \
-H "Content-Type: application/json" \
-d '{"run_id":"<uuid>","page_title":"SLOC Report 2025-05-10"}' \
<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/post</pre>
<button class="curl-copy-btn" data-target="c-cf-post">Copy</button>
</div>
</div>
</div>
<div class="ep-card">
<div class="ep-header">
<span class="method get">GET</span>
<span class="ep-path">/api/confluence/wiki-markup</span>
<span class="auth-badge protected">Protected</span>
<span class="ep-desc">Get Confluence wiki markup for a run</span>
<svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="ep-body">
<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>
<p class="params-heading">Query Parameters</p>
<table class="params">
<tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
<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>
</table>
<p class="curl-heading">Example</p>
<div class="curl-wrap">
<pre class="curl-block" data-curl-id="c-cf-markup">curl -H "Authorization: Bearer $SLOC_API_KEY" \
"<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/wiki-markup?run_id=<uuid>"</pre>
<button class="curl-copy-btn" data-target="c-cf-markup">Copy</button>
</div>
</div>
</div>
</div>
<!-- Authentication -->
<div class="section">
<h2 class="section-title">Authentication</h2>
<p class="webhook-note">These endpoints are always public. They manage browser session cookies used as an alternative to API key headers.</p>
<div class="ep-card">
<div class="ep-header">
<span class="method get">GET</span>
<span class="ep-path">/auth/login</span>
<span class="auth-badge public">Public</span>
<span class="ep-desc">Login page</span>
<svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="ep-body">
<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>
<p class="params-heading">Query Parameters</p>
<table class="params">
<tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
<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>
<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>
</table>
</div>
</div>
<div class="ep-card">
<div class="ep-header">
<span class="method post">POST</span>
<span class="ep-path">/auth/login</span>
<span class="auth-badge public">Public</span>
<span class="ep-desc">Submit credentials and get a session cookie</span>
<svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="ep-body">
<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>
<p class="params-heading">Form Body (application/x-www-form-urlencoded)</p>
<table class="params">
<tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
<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>
<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>
</table>
<p class="curl-heading">Example</p>
<div class="curl-wrap">
<pre class="curl-block" data-curl-id="c-auth-login">curl -c cookies.txt -X POST \
-d "key=$SLOC_API_KEY&next=/" \
<span class="base-url-slot">http://127.0.0.1:4317</span>/auth/login</pre>
<button class="curl-copy-btn" data-target="c-auth-login">Copy</button>
</div>
</div>
</div>
</div>
<!-- Coverage Suggestion -->
<div class="section">
<h2 class="section-title">Coverage Suggestion</h2>
<div class="ep-card">
<div class="ep-header">
<span class="method get">GET</span>
<span class="ep-path">/api/suggest-coverage</span>
<span class="auth-badge protected">Protected</span>
<span class="ep-desc">Auto-detect a coverage file for a project root</span>
<svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="ep-body">
<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>
<p class="params-heading">Query Parameters</p>
<table class="params">
<tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
<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>
</table>
<details class="schema"><summary>Response schema</summary>
<div class="schema-block">{
"found": string | null, // absolute path to the coverage file, if detected
"tool": string | null, // detected coverage tool (e.g. "cargo-llvm-cov", "jacoco", "pytest-cov")
"hint": string | null // shell command to generate coverage if not found
}</div></details>
<p class="curl-heading">Example</p>
<div class="curl-wrap">
<pre class="curl-block" data-curl-id="c-suggest-cov">curl -H "Authorization: Bearer $SLOC_API_KEY" \
"<span class="base-url-slot">http://127.0.0.1:4317</span>/api/suggest-coverage?path=/path/to/repo"</pre>
<button class="curl-copy-btn" data-target="c-suggest-cov">Copy</button>
</div>
</div>
</div>
</div>
</div>
<footer class="site-footer">
local code analysis - metrics, history and reports
· <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
· Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
· <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
· <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
· <a href="/api-docs" rel="noopener">REST API</a>
</footer>
<script nonce="{{ csp_nonce }}">
(function () {
var base = window.location.origin;
document.getElementById('base-url').textContent = base;
document.querySelectorAll('.base-url-slot').forEach(function (el) {
el.textContent = base;
});
document.querySelectorAll('.ep-header').forEach(function (hdr) {
hdr.addEventListener('click', function () {
hdr.closest('.ep-card').classList.toggle('open');
});
});
document.querySelectorAll('.curl-copy-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
var targetId = btn.dataset.target;
var pre = document.querySelector('[data-curl-id="' + targetId + '"]');
if (!pre) return;
navigator.clipboard.writeText(pre.textContent).then(function () {
btn.textContent = 'Copied!';
btn.classList.add('copied');
setTimeout(function () {
btn.textContent = 'Copy';
btn.classList.remove('copied');
}, 2000);
});
});
});
var storageKey = 'oxide-sloc-theme';
try { document.body.classList.toggle('dark-theme', JSON.parse(localStorage.getItem(storageKey))); } catch (e) {}
var themeBtn = document.getElementById('theme-toggle');
if (themeBtn) {
themeBtn.addEventListener('click', function () {
var dark = document.body.classList.toggle('dark-theme');
try { localStorage.setItem(storageKey, JSON.stringify(dark)); } catch (e) {}
});
}
(function() {
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'}];
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);});}
try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
var btn=document.getElementById('settings-btn');if(!btn)return;
var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
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>';
document.body.appendChild(m);
var g=document.getElementById('scheme-grid');
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);});
var cl=document.getElementById('settings-close');
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);
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');});
if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
})();
(function randomizeWatermarks() {
var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
if (!wms.length) return;
var placed = [];
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;
}
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];
}
var half = Math.floor(wms.length / 2);
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;
});
})();
(function spawnCodeParticles() {
var container = document.getElementById('code-particles');
if (!container) return;
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'
];
var count = 38;
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.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
container.appendChild(el);
})(i);
}
})();
}());
</script>
</body>
</html>
"##,
ext = "html"
)]
struct ApiDocsTemplate {
has_api_key: bool,
csp_nonce: String,
version: &'static str,
}
#[cfg(test)]
mod form_config_tests {
use super::*;
use sloc_config::{
BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy, MixedLinePolicy,
};
fn blank_form() -> AnalyzeForm {
AnalyzeForm {
path: ".".to_string(),
git_repo: None,
git_ref: None,
mixed_line_policy: None,
python_docstrings_as_comments: None,
generated_file_detection: None,
minified_file_detection: None,
vendor_directory_detection: None,
include_lockfiles: None,
binary_file_behavior: None,
output_dir: None,
report_title: None,
report_header_footer: None,
include_globs: None,
exclude_globs: None,
submodule_breakdown: None,
coverage_file: None,
continuation_line_policy: None,
blank_in_block_comment_policy: None,
count_compiler_directives: None,
style_col_threshold: None,
style_analysis_enabled: None,
style_score_threshold: None,
style_lang_scope: None,
cocomo_mode: None,
complexity_alert: None,
exclude_duplicates: None,
}
}
fn apply(form: &AnalyzeForm) -> sloc_config::AppConfig {
let mut cfg = sloc_config::AppConfig::default();
apply_form_to_config(&mut cfg, form);
cfg
}
// ── python_docstrings_as_comments (checkbox, no value attr → sends "on") ──
#[test]
fn python_docstrings_false_when_unchecked() {
// Checkbox absent in form data (unchecked) → field must be false.
let cfg = apply(&blank_form());
assert!(
!cfg.analysis.python_docstrings_as_comments,
"absent python_docstrings_as_comments must map to false"
);
}
#[test]
fn python_docstrings_true_when_checked() {
// Browser sends "on" (no value= attr on the checkbox).
let mut form = blank_form();
form.python_docstrings_as_comments = Some("on".to_string());
let cfg = apply(&form);
assert!(cfg.analysis.python_docstrings_as_comments);
}
#[test]
fn python_docstrings_true_for_any_non_none_value() {
// The handler uses .is_some() — any non-None value means "checked".
let mut form = blank_form();
form.python_docstrings_as_comments = Some("true".to_string());
assert!(apply(&form).analysis.python_docstrings_as_comments);
}
// ── submodule_breakdown (checkbox with value="enabled") ──
#[test]
fn submodule_breakdown_false_when_unchecked() {
let cfg = apply(&blank_form());
assert!(
!cfg.discovery.submodule_breakdown,
"absent submodule_breakdown must map to false"
);
}
#[test]
fn submodule_breakdown_true_when_value_enabled() {
let mut form = blank_form();
form.submodule_breakdown = Some("enabled".to_string());
assert!(apply(&form).discovery.submodule_breakdown);
}
#[test]
fn submodule_breakdown_false_for_wrong_value() {
// If somehow a value other than "enabled" is sent, it must still be false.
let mut form = blank_form();
form.submodule_breakdown = Some("on".to_string());
assert!(
!apply(&form).discovery.submodule_breakdown,
"submodule_breakdown only becomes true for the exact value 'enabled'"
);
}
// ── generated_file_detection (select: "enabled" | "disabled") ──
#[test]
fn generated_detection_true_when_enabled() {
let mut form = blank_form();
form.generated_file_detection = Some("enabled".to_string());
assert!(apply(&form).analysis.generated_file_detection);
}
#[test]
fn generated_detection_false_when_disabled() {
let mut form = blank_form();
form.generated_file_detection = Some("disabled".to_string());
assert!(!apply(&form).analysis.generated_file_detection);
}
#[test]
fn generated_detection_true_when_absent() {
// None != Some("disabled") → true (safe default)
assert!(
apply(&blank_form()).analysis.generated_file_detection,
"absent field must default to true (detection on)"
);
}
// ── minified_file_detection ──
#[test]
fn minified_detection_false_when_disabled() {
let mut form = blank_form();
form.minified_file_detection = Some("disabled".to_string());
assert!(!apply(&form).analysis.minified_file_detection);
}
#[test]
fn minified_detection_true_when_enabled() {
let mut form = blank_form();
form.minified_file_detection = Some("enabled".to_string());
assert!(apply(&form).analysis.minified_file_detection);
}
#[test]
fn minified_detection_true_when_absent() {
assert!(apply(&blank_form()).analysis.minified_file_detection);
}
// ── vendor_directory_detection ──
#[test]
fn vendor_detection_false_when_disabled() {
let mut form = blank_form();
form.vendor_directory_detection = Some("disabled".to_string());
assert!(!apply(&form).analysis.vendor_directory_detection);
}
#[test]
fn vendor_detection_true_when_enabled() {
let mut form = blank_form();
form.vendor_directory_detection = Some("enabled".to_string());
assert!(apply(&form).analysis.vendor_directory_detection);
}
#[test]
fn vendor_detection_true_when_absent() {
assert!(apply(&blank_form()).analysis.vendor_directory_detection);
}
// ── include_lockfiles (select: "disabled" default | "enabled") ──
#[test]
fn lockfiles_false_when_absent() {
// None == Some("enabled") is false → lockfiles off (correct safe default)
assert!(!apply(&blank_form()).analysis.include_lockfiles);
}
#[test]
fn lockfiles_false_when_disabled() {
let mut form = blank_form();
form.include_lockfiles = Some("disabled".to_string());
assert!(!apply(&form).analysis.include_lockfiles);
}
#[test]
fn lockfiles_true_when_enabled() {
let mut form = blank_form();
form.include_lockfiles = Some("enabled".to_string());
assert!(apply(&form).analysis.include_lockfiles);
}
// ── count_compiler_directives ──
#[test]
fn compiler_directives_true_when_absent() {
assert!(
apply(&blank_form()).analysis.count_compiler_directives,
"absent count_compiler_directives must default to true"
);
}
#[test]
fn compiler_directives_true_when_enabled() {
let mut form = blank_form();
form.count_compiler_directives = Some("enabled".to_string());
assert!(apply(&form).analysis.count_compiler_directives);
}
#[test]
fn compiler_directives_false_when_disabled() {
let mut form = blank_form();
form.count_compiler_directives = Some("disabled".to_string());
assert!(!apply(&form).analysis.count_compiler_directives);
}
// ── mixed_line_policy (enum select) ──
#[test]
fn mixed_policy_unchanged_when_absent() {
// None → if-let does nothing → stays at config default (CodeOnly)
assert_eq!(
apply(&blank_form()).analysis.mixed_line_policy,
MixedLinePolicy::CodeOnly
);
}
#[test]
fn mixed_policy_code_only() {
let mut form = blank_form();
form.mixed_line_policy = Some(MixedLinePolicy::CodeOnly);
assert_eq!(
apply(&form).analysis.mixed_line_policy,
MixedLinePolicy::CodeOnly
);
}
#[test]
fn mixed_policy_code_and_comment() {
let mut form = blank_form();
form.mixed_line_policy = Some(MixedLinePolicy::CodeAndComment);
assert_eq!(
apply(&form).analysis.mixed_line_policy,
MixedLinePolicy::CodeAndComment
);
}
#[test]
fn mixed_policy_comment_only() {
let mut form = blank_form();
form.mixed_line_policy = Some(MixedLinePolicy::CommentOnly);
assert_eq!(
apply(&form).analysis.mixed_line_policy,
MixedLinePolicy::CommentOnly
);
}
#[test]
fn mixed_policy_separate_mixed_category() {
let mut form = blank_form();
form.mixed_line_policy = Some(MixedLinePolicy::SeparateMixedCategory);
assert_eq!(
apply(&form).analysis.mixed_line_policy,
MixedLinePolicy::SeparateMixedCategory
);
}
// ── binary_file_behavior (enum select) ──
#[test]
fn binary_behavior_skip_when_absent() {
assert_eq!(
apply(&blank_form()).analysis.binary_file_behavior,
BinaryFileBehavior::Skip
);
}
#[test]
fn binary_behavior_skip() {
let mut form = blank_form();
form.binary_file_behavior = Some(BinaryFileBehavior::Skip);
assert_eq!(
apply(&form).analysis.binary_file_behavior,
BinaryFileBehavior::Skip
);
}
#[test]
fn binary_behavior_fail() {
let mut form = blank_form();
form.binary_file_behavior = Some(BinaryFileBehavior::Fail);
assert_eq!(
apply(&form).analysis.binary_file_behavior,
BinaryFileBehavior::Fail
);
}
// ── continuation_line_policy (enum select) ──
#[test]
fn continuation_policy_each_physical_when_absent() {
assert_eq!(
apply(&blank_form()).analysis.continuation_line_policy,
ContinuationLinePolicy::EachPhysicalLine
);
}
#[test]
fn continuation_policy_collapse_to_logical() {
let mut form = blank_form();
form.continuation_line_policy = Some(ContinuationLinePolicy::CollapseToLogical);
assert_eq!(
apply(&form).analysis.continuation_line_policy,
ContinuationLinePolicy::CollapseToLogical
);
}
// ── blank_in_block_comment_policy (enum select) ──
#[test]
fn blank_in_block_comment_count_as_comment_when_absent() {
assert_eq!(
apply(&blank_form()).analysis.blank_in_block_comment_policy,
BlankInBlockCommentPolicy::CountAsComment
);
}
#[test]
fn blank_in_block_comment_count_as_blank() {
let mut form = blank_form();
form.blank_in_block_comment_policy = Some(BlankInBlockCommentPolicy::CountAsBlank);
assert_eq!(
apply(&form).analysis.blank_in_block_comment_policy,
BlankInBlockCommentPolicy::CountAsBlank
);
}
// ── style_col_threshold ──
#[test]
fn style_threshold_80() {
let mut form = blank_form();
form.style_col_threshold = Some("80".to_string());
assert_eq!(apply(&form).analysis.style_col_threshold, 80);
}
#[test]
fn style_threshold_100() {
let mut form = blank_form();
form.style_col_threshold = Some("100".to_string());
assert_eq!(apply(&form).analysis.style_col_threshold, 100);
}
#[test]
fn style_threshold_120() {
let mut form = blank_form();
form.style_col_threshold = Some("120".to_string());
assert_eq!(apply(&form).analysis.style_col_threshold, 120);
}
#[test]
fn style_threshold_invalid_value_leaves_default() {
// 42 is not in the allowed set {80, 100, 120} — must be ignored.
let mut cfg = sloc_config::AppConfig::default();
let mut form = blank_form();
form.style_col_threshold = Some("42".to_string());
apply_form_to_config(&mut cfg, &form);
assert_eq!(
cfg.analysis.style_col_threshold, 80,
"invalid threshold must not change config"
);
}
#[test]
fn style_threshold_non_numeric_leaves_default() {
let mut cfg = sloc_config::AppConfig::default();
let mut form = blank_form();
form.style_col_threshold = Some("large".to_string());
apply_form_to_config(&mut cfg, &form);
assert_eq!(cfg.analysis.style_col_threshold, 80);
}
#[test]
fn style_threshold_zero_leaves_default() {
let mut cfg = sloc_config::AppConfig::default();
let mut form = blank_form();
form.style_col_threshold = Some("0".to_string());
apply_form_to_config(&mut cfg, &form);
assert_eq!(cfg.analysis.style_col_threshold, 80);
}
#[test]
fn style_threshold_absent_leaves_default() {
assert_eq!(apply(&blank_form()).analysis.style_col_threshold, 80);
}
// ── coverage_file ──
#[test]
fn coverage_file_none_when_absent() {
assert!(apply(&blank_form()).analysis.coverage_file.is_none());
}
#[test]
fn coverage_file_none_when_whitespace_only() {
let mut form = blank_form();
form.coverage_file = Some(" ".to_string());
assert!(
apply(&form).analysis.coverage_file.is_none(),
"whitespace-only coverage_file must be treated as None"
);
}
#[test]
fn coverage_file_set_when_non_empty() {
let mut form = blank_form();
form.coverage_file = Some("coverage/lcov.info".to_string());
assert_eq!(
apply(&form).analysis.coverage_file,
Some(std::path::PathBuf::from("coverage/lcov.info"))
);
}
#[test]
fn coverage_file_trims_whitespace() {
let mut form = blank_form();
form.coverage_file = Some(" coverage/lcov.info ".to_string());
assert_eq!(
apply(&form).analysis.coverage_file,
Some(std::path::PathBuf::from("coverage/lcov.info"))
);
}
// ── report_title ──
#[test]
fn report_title_unchanged_when_absent() {
let original = sloc_config::AppConfig::default().reporting.report_title;
assert_eq!(apply(&blank_form()).reporting.report_title, original);
}
#[test]
fn report_title_unchanged_when_whitespace_only() {
let original = sloc_config::AppConfig::default().reporting.report_title;
let mut form = blank_form();
form.report_title = Some(" ".to_string());
assert_eq!(
apply(&form).reporting.report_title,
original,
"whitespace-only title must not overwrite the default"
);
}
#[test]
fn report_title_updated_and_trimmed() {
let mut form = blank_form();
form.report_title = Some(" My Project ".to_string());
assert_eq!(apply(&form).reporting.report_title, "My Project");
}
// ── report_header_footer ──
#[test]
fn header_footer_none_when_absent() {
assert!(apply(&blank_form())
.reporting
.report_header_footer
.is_none());
}
#[test]
fn header_footer_none_when_whitespace_only() {
let mut form = blank_form();
form.report_header_footer = Some(" ".to_string());
assert!(apply(&form).reporting.report_header_footer.is_none());
}
#[test]
fn header_footer_set_and_trimmed() {
let mut form = blank_form();
form.report_header_footer = Some(" Confidential — Internal Use ".to_string());
assert_eq!(
apply(&form).reporting.report_header_footer,
Some("Confidential — Internal Use".to_string())
);
}
// ── include_globs / exclude_globs ──
#[test]
fn include_globs_empty_when_absent() {
assert!(apply(&blank_form()).discovery.include_globs.is_empty());
}
#[test]
fn include_globs_newline_separated() {
let mut form = blank_form();
form.include_globs = Some("src/**/*.rs\ntests/**/*.rs".to_string());
assert_eq!(
apply(&form).discovery.include_globs,
vec!["src/**/*.rs", "tests/**/*.rs"]
);
}
#[test]
fn exclude_globs_comma_separated() {
let mut form = blank_form();
form.exclude_globs = Some("vendor/**,node_modules/**".to_string());
assert_eq!(
apply(&form).discovery.exclude_globs,
vec!["vendor/**", "node_modules/**"]
);
}
#[test]
fn globs_mixed_separators() {
let mut form = blank_form();
form.exclude_globs = Some("a/**\nb/**,c/**".to_string());
assert_eq!(
apply(&form).discovery.exclude_globs,
vec!["a/**", "b/**", "c/**"]
);
}
// ── split_patterns unit tests ──
#[test]
fn split_patterns_none_is_empty() {
assert!(split_patterns(None).is_empty());
}
#[test]
fn split_patterns_empty_string_is_empty() {
assert!(split_patterns(Some("")).is_empty());
}
#[test]
fn split_patterns_whitespace_only_is_empty() {
assert!(split_patterns(Some(" \n \n ")).is_empty());
}
#[test]
fn split_patterns_newlines() {
assert_eq!(
split_patterns(Some("a/**\nb/**\nc/**")),
vec!["a/**", "b/**", "c/**"]
);
}
#[test]
fn split_patterns_commas() {
assert_eq!(
split_patterns(Some("a/**,b/**,c/**")),
vec!["a/**", "b/**", "c/**"]
);
}
#[test]
fn split_patterns_mixed() {
assert_eq!(
split_patterns(Some("a/**\nb/**,c/**")),
vec!["a/**", "b/**", "c/**"]
);
}
#[test]
fn split_patterns_trims_whitespace() {
assert_eq!(
split_patterns(Some(" a/** \n b/** ")),
vec!["a/**", "b/**"]
);
}
#[test]
fn split_patterns_filters_empty_entries() {
assert_eq!(split_patterns(Some(",\n,,a/**,,\n")), vec!["a/**"]);
}
#[test]
fn split_patterns_single_entry() {
assert_eq!(split_patterns(Some("src/**")), vec!["src/**"]);
}
}
#[cfg(test)]
mod utility_tests {
use super::*;
use std::net::IpAddr;
use std::time::Duration;
// ── sanitize_project_label ────────────────────────────────────────────────
#[test]
fn sanitize_simple_name() {
assert_eq!(sanitize_project_label("myrepo"), "myrepo");
}
#[test]
fn sanitize_uppercased_lowercased() {
assert_eq!(sanitize_project_label("MyRepo"), "myrepo");
}
#[test]
fn sanitize_path_extracts_filename() {
assert_eq!(
sanitize_project_label("/home/user/my-project"),
"my-project"
);
}
#[test]
fn sanitize_path_uses_last_component() {
assert_eq!(sanitize_project_label("/a/b/c/d"), "d");
}
#[test]
fn sanitize_spaces_become_hyphens() {
assert_eq!(sanitize_project_label("my project"), "my-project");
}
#[test]
fn sanitize_non_ascii_become_hyphens() {
assert_eq!(sanitize_project_label("proj\u{00e9}ct"), "proj-ct");
}
#[test]
fn sanitize_all_special_chars_gives_project() {
assert_eq!(sanitize_project_label("!@#$%^"), "project");
}
#[test]
fn sanitize_empty_string_gives_project() {
assert_eq!(sanitize_project_label(""), "project");
}
#[test]
fn sanitize_leading_trailing_hyphens_stripped() {
assert_eq!(sanitize_project_label("!myrepo!"), "myrepo");
}
#[test]
fn sanitize_alphanumeric_preserved() {
assert_eq!(sanitize_project_label("repo123"), "repo123");
}
#[test]
fn sanitize_dots_become_hyphens() {
assert_eq!(sanitize_project_label("my.repo.name"), "my-repo-name");
}
#[test]
fn sanitize_mixed_slashes_uses_filename() {
// The Windows path separator — on all platforms Path::file_name still works
assert_eq!(sanitize_project_label("project-name"), "project-name");
}
// ── IpRateLimiter ─────────────────────────────────────────────────────────
#[test]
fn rate_limiter_allows_first_request() {
let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_hours(1));
let ip: IpAddr = "127.0.0.1".parse().unwrap();
assert!(rl.is_allowed(ip));
}
#[test]
fn rate_limiter_blocks_after_limit_reached() {
let rl = IpRateLimiter::new(Duration::from_mins(1), 3, 5, Duration::from_hours(1));
let ip: IpAddr = "10.0.0.1".parse().unwrap();
assert!(rl.is_allowed(ip));
assert!(rl.is_allowed(ip));
assert!(rl.is_allowed(ip));
assert!(!rl.is_allowed(ip), "4th request must be blocked");
}
#[test]
fn rate_limiter_allows_requests_up_to_limit() {
let rl = IpRateLimiter::new(Duration::from_mins(1), 5, 5, Duration::from_hours(1));
let ip: IpAddr = "10.0.0.2".parse().unwrap();
for _ in 0..5 {
assert!(rl.is_allowed(ip));
}
assert!(!rl.is_allowed(ip), "6th request must be blocked");
}
#[test]
fn rate_limiter_different_ips_are_independent() {
let rl = IpRateLimiter::new(Duration::from_mins(1), 1, 5, Duration::from_hours(1));
let ip1: IpAddr = "192.168.1.1".parse().unwrap();
let ip2: IpAddr = "192.168.1.2".parse().unwrap();
assert!(rl.is_allowed(ip1));
assert!(!rl.is_allowed(ip1), "ip1 blocked after limit");
assert!(rl.is_allowed(ip2), "ip2 must be independent");
}
#[test]
fn rate_limiter_auth_failure_not_locked_below_threshold() {
let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
let ip: IpAddr = "10.0.0.3".parse().unwrap();
rl.record_auth_failure(ip);
rl.record_auth_failure(ip);
assert!(
!rl.is_auth_locked_out(ip),
"not locked at 2 failures when threshold is 3"
);
}
#[test]
fn rate_limiter_auth_failure_locked_at_threshold() {
let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
let ip: IpAddr = "10.0.0.4".parse().unwrap();
rl.record_auth_failure(ip);
rl.record_auth_failure(ip);
rl.record_auth_failure(ip);
assert!(rl.is_auth_locked_out(ip), "must be locked after 3 failures");
}
#[test]
fn rate_limiter_auth_failure_different_ips_independent() {
let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 2, Duration::from_hours(1));
let ip1: IpAddr = "10.0.1.1".parse().unwrap();
let ip2: IpAddr = "10.0.1.2".parse().unwrap();
rl.record_auth_failure(ip1);
rl.record_auth_failure(ip1);
assert!(rl.is_auth_locked_out(ip1));
assert!(!rl.is_auth_locked_out(ip2), "ip2 must not be locked");
}
#[test]
fn rate_limiter_high_limit_never_blocks_normal_traffic() {
let rl = IpRateLimiter::new(Duration::from_mins(1), 1000, 10, Duration::from_hours(1));
let ip: IpAddr = "127.0.0.2".parse().unwrap();
for _ in 0..100 {
assert!(rl.is_allowed(ip));
}
}
// ── strip_unc_prefix ──────────────────────────────────────────────────────
#[test]
fn strip_unc_plain_path_unchanged() {
let p = PathBuf::from("C:\\Users\\user\\project");
let result = strip_unc_prefix(p.clone());
assert_eq!(result, p);
}
#[test]
fn strip_unc_with_drive_prefix_stripped() {
let p = PathBuf::from(r"\\?\C:\Users\user\project");
let result = strip_unc_prefix(p);
assert_eq!(result, PathBuf::from(r"C:\Users\user\project"));
}
#[test]
fn strip_unc_with_network_prefix_stripped() {
let p = PathBuf::from(r"\\?\UNC\server\share\dir");
let result = strip_unc_prefix(p);
assert_eq!(result, PathBuf::from(r"\\server\share\dir"));
}
#[test]
fn strip_unc_linux_path_unchanged() {
let p = PathBuf::from("/home/user/project");
let result = strip_unc_prefix(p.clone());
assert_eq!(result, p);
}
// ── remote_to_commit_url ──────────────────────────────────────────────────
#[test]
fn remote_to_commit_url_github_https() {
let url = remote_to_commit_url("https://github.com/owner/repo.git", "abc1234");
assert_eq!(
url,
Some("https://github.com/owner/repo/commit/abc1234".to_owned())
);
}
#[test]
fn remote_to_commit_url_github_ssh() {
let url = remote_to_commit_url("git@github.com:owner/repo.git", "abc1234");
assert_eq!(
url,
Some("https://github.com/owner/repo/commit/abc1234".to_owned())
);
}
#[test]
fn remote_to_commit_url_gitlab_uses_dash_commit() {
let url = remote_to_commit_url("https://gitlab.com/group/repo.git", "deadbeef");
assert_eq!(
url,
Some("https://gitlab.com/group/repo/-/commit/deadbeef".to_owned())
);
}
#[test]
fn remote_to_commit_url_bitbucket_uses_commits() {
let url = remote_to_commit_url("https://bitbucket.org/workspace/repo.git", "cafebabe");
assert_eq!(
url,
Some("https://bitbucket.org/workspace/repo/commits/cafebabe".to_owned())
);
}
#[test]
fn remote_to_commit_url_unknown_scheme_returns_none() {
let url = remote_to_commit_url("ftp://example.com/repo.git", "abc");
assert!(url.is_none());
}
#[test]
fn remote_to_commit_url_ssh_gitlab() {
let url = remote_to_commit_url("git@gitlab.com:group/repo.git", "sha123");
assert!(url.is_some());
let u = url.unwrap();
assert!(
u.contains("/-/commit/sha123"),
"gitlab ssh must use /-/commit/"
);
}
// ── git_clone_dest ────────────────────────────────────────────────────────
#[test]
fn git_clone_dest_github_url_produces_safe_name() {
let dir = PathBuf::from("/tmp/clones");
let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
let name = dest.file_name().unwrap().to_string_lossy();
assert!(!name.is_empty());
assert!(
name.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.'),
"clone dest must only contain safe chars, got: {name}"
);
}
#[test]
fn git_clone_dest_is_inside_clones_dir() {
let dir = PathBuf::from("/tmp/clones");
let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
assert!(
dest.starts_with(&dir),
"clone dest must be inside clones_dir"
);
}
#[test]
fn git_clone_dest_truncates_to_80_chars_max() {
let long_url = "https://github.com/".to_string() + &"a".repeat(200);
let dir = PathBuf::from("/tmp/clones");
let dest = git_clone_dest(&long_url, &dir);
let name = dest.file_name().unwrap().to_string_lossy();
assert!(
name.len() <= 80,
"clone dest name must be at most 80 chars, got {} chars: {name}",
name.len()
);
}
#[test]
fn git_clone_dest_special_chars_replaced_with_underscore() {
let dir = PathBuf::from("/tmp/clones");
let dest = git_clone_dest("git@github.com:owner/repo.git", &dir);
let name = dest.file_name().unwrap().to_string_lossy();
assert!(
!name.contains('@') && !name.contains(':') && !name.contains('/'),
"special chars must be replaced in clone dest, got: {name}"
);
}
#[test]
fn git_clone_dest_different_urls_differ() {
let dir = PathBuf::from("/tmp/clones");
let a = git_clone_dest("https://github.com/owner/repo-a.git", &dir);
let b = git_clone_dest("https://github.com/owner/repo-b.git", &dir);
assert_ne!(
a, b,
"different repos must produce different clone dest names"
);
}
#[test]
fn git_clone_dest_same_url_same_result() {
let dir = PathBuf::from("/tmp/clones");
let url = "https://github.com/owner/repo.git";
assert_eq!(
git_clone_dest(url, &dir),
git_clone_dest(url, &dir),
"same URL must always give same clone dest"
);
}
// ── fmt_delta ─────────────────────────────────────────────────────────────
#[test]
fn fmt_delta_positive_has_plus_prefix() {
assert_eq!(fmt_delta(5), "+5");
}
#[test]
fn fmt_delta_negative_no_plus_prefix() {
assert_eq!(fmt_delta(-3), "-3");
}
#[test]
fn fmt_delta_zero() {
assert_eq!(fmt_delta(0), "0");
}
// ── delta_class ───────────────────────────────────────────────────────────
#[test]
fn delta_class_positive_is_pos() {
assert_eq!(delta_class(1), "pos");
}
#[test]
fn delta_class_negative_is_neg() {
assert_eq!(delta_class(-1), "neg");
}
#[test]
fn delta_class_zero_is_zero_class() {
assert_eq!(delta_class(0), "zero");
}
// ── fmt_pct ───────────────────────────────────────────────────────────────
#[test]
fn fmt_pct_zero_baseline_returns_em_dash() {
assert_eq!(fmt_pct(100, 0), "\u{2014}");
}
#[test]
fn fmt_pct_positive_delta_has_plus_sign() {
let result = fmt_pct(10, 100);
assert!(result.starts_with('+'), "expected + prefix, got: {result}");
}
#[test]
fn fmt_pct_negative_delta_no_plus_sign() {
let result = fmt_pct(-10, 100);
assert!(!result.starts_with('+'), "unexpected + in: {result}");
assert!(result.contains('%'));
}
#[test]
fn fmt_pct_near_zero_returns_pm_zero() {
assert_eq!(fmt_pct(0, 1000), "\u{00b1}0%");
}
// ── summary_delta ─────────────────────────────────────────────────────────
#[test]
fn summary_delta_no_prev_returns_dash_na() {
let (display, class) = summary_delta(10, None);
assert_eq!(display, "\u{2014}");
assert_eq!(class, "na");
}
#[test]
fn summary_delta_increase_is_positive() {
let (display, class) = summary_delta(15, Some(10));
assert_eq!(display, "+5");
assert_eq!(class, "pos");
}
#[test]
fn summary_delta_decrease_is_negative() {
let (display, class) = summary_delta(5, Some(10));
assert_eq!(display, "-5");
assert_eq!(class, "neg");
}
// ── nth_weekday_of_month ──────────────────────────────────────────────────
#[test]
fn nth_weekday_first_monday_jan_2024_is_in_first_week() {
use chrono::Datelike;
let d = nth_weekday_of_month(2024, 1, chrono::Weekday::Mon, 1);
assert_eq!(d.year(), 2024);
assert_eq!(d.month(), 1);
assert_eq!(d.weekday(), chrono::Weekday::Mon);
assert!(d.day() <= 7);
}
#[test]
fn nth_weekday_second_sunday_march_2024_is_10th() {
use chrono::Datelike;
let d = nth_weekday_of_month(2024, 3, chrono::Weekday::Sun, 2);
assert_eq!(d.weekday(), chrono::Weekday::Sun);
assert_eq!(d.month(), 3);
assert_eq!(d.day(), 10, "2nd Sunday in March 2024 is the 10th");
}
// ── is_pacific_dst / fmt_la_time / fmt_la_time_meta ───────────────────────
#[test]
fn is_pacific_dst_july_is_true() {
let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
assert!(is_pacific_dst(dt), "July must be PDT");
}
#[test]
fn is_pacific_dst_january_is_false() {
let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
assert!(!is_pacific_dst(dt), "January must be PST");
}
#[test]
fn fmt_la_time_summer_shows_pdt() {
let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
let result = fmt_la_time(dt);
assert!(
result.ends_with("PDT"),
"summer must use PDT, got: {result}"
);
}
#[test]
fn fmt_la_time_winter_shows_pst() {
let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
let result = fmt_la_time(dt);
assert!(
result.ends_with("PST"),
"winter must use PST, got: {result}"
);
}
#[test]
fn fmt_la_time_meta_summer_shows_pdt() {
let dt: chrono::DateTime<chrono::Utc> = "2024-08-01T12:00:00Z".parse().unwrap();
let result = fmt_la_time_meta(dt);
assert!(
result.ends_with("PDT"),
"meta summer must use PDT, got: {result}"
);
}
#[test]
fn fmt_la_time_meta_winter_shows_pst() {
let dt: chrono::DateTime<chrono::Utc> = "2024-12-01T12:00:00Z".parse().unwrap();
let result = fmt_la_time_meta(dt);
assert!(
result.ends_with("PST"),
"meta winter must use PST, got: {result}"
);
}
// ── fmt_git_date ──────────────────────────────────────────────────────────
#[test]
fn fmt_git_date_valid_iso_returns_some() {
assert!(fmt_git_date("2024-07-15T20:00:00Z").is_some());
}
#[test]
fn fmt_git_date_invalid_returns_none() {
assert!(fmt_git_date("not-a-date").is_none());
}
// ── format_number ─────────────────────────────────────────────────────────
#[test]
fn format_number_zero() {
assert_eq!(format_number(0), "0");
}
#[test]
fn format_number_three_digits_no_comma() {
assert_eq!(format_number(999), "999");
}
#[test]
fn format_number_four_digits_has_comma() {
assert_eq!(format_number(1000), "1,000");
}
#[test]
fn format_number_seven_digits_two_commas() {
assert_eq!(format_number(1_234_567), "1,234,567");
}
#[test]
fn format_number_one_million() {
assert_eq!(format_number(1_000_000), "1,000,000");
}
// ── badge_text_px / render_badge_svg ──────────────────────────────────────
#[test]
fn badge_text_px_empty_is_zero() {
assert_eq!(badge_text_px(""), 0);
}
#[test]
fn badge_text_px_narrow_chars_smaller_than_normal() {
assert!(
badge_text_px("if") < badge_text_px("ab"),
"'if' must be narrower than 'ab'"
);
}
#[test]
fn badge_text_px_m_is_wider_than_a() {
assert!(
badge_text_px("m") > badge_text_px("a"),
"'m' must be wider than 'a'"
);
}
#[test]
fn render_badge_svg_contains_label_and_value() {
let svg = render_badge_svg("coverage", "95%", "#4c1");
assert!(svg.contains("coverage") && svg.contains("95%"));
}
#[test]
fn render_badge_svg_contains_color() {
let svg = render_badge_svg("sloc", "12K", "#e05d44");
assert!(svg.contains("#e05d44"), "SVG must contain fill color");
}
#[test]
fn render_badge_svg_escapes_ampersand_in_label() {
let svg = render_badge_svg("test&label", "ok", "#4c1");
assert!(svg.contains("&") && !svg.contains("test&label"));
}
// ── build_pdf_filename ────────────────────────────────────────────────────
#[test]
fn build_pdf_filename_slugifies_title() {
let name = build_pdf_filename("My Project Report", "abc-def-1234");
assert!(
name.starts_with("my_project_report_")
&& std::path::Path::new(&name)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
);
}
#[test]
fn build_pdf_filename_uses_last_run_id_segment() {
let name = build_pdf_filename("project", "uuid-part1-part2-ABCD");
assert!(name.contains("ABCD"), "must use last segment of run_id");
}
#[test]
fn build_pdf_filename_empty_title_uses_report_prefix() {
let name = build_pdf_filename("", "abc-def-9999");
assert!(
name.starts_with("report_")
&& std::path::Path::new(&name)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
);
}
// ── swap_inline_chart_js_for_static ───────────────────────────────────────
#[test]
fn swap_chart_js_replaces_inline_block() {
let html = "<html><head><script>// inline source</script></head><body></body></html>";
let result = swap_inline_chart_js_for_static(html.to_string());
assert!(result.contains(r#"src="/static/chart-report.js""#));
assert!(!result.contains("inline source"));
}
#[test]
fn swap_chart_js_no_head_returns_unchanged() {
let html = "<body>no head here</body>";
assert_eq!(swap_inline_chart_js_for_static(html.to_string()), html);
}
#[test]
fn swap_chart_js_no_script_in_head_unchanged() {
let html = "<html><head><style>.x{}</style></head><body></body></html>";
let result = swap_inline_chart_js_for_static(html.to_string());
assert!(!result.contains("chart-report.js"));
}
// ── patch_html_nonce ──────────────────────────────────────────────────────
#[test]
fn patch_html_nonce_replaces_old_nonce() {
let html = r#"<style nonce="old-nonce-123">body{}</style>"#;
let result = patch_html_nonce(html, "new-nonce-456");
assert!(result.contains(r#"nonce="new-nonce-456""#));
assert!(!result.contains("old-nonce-123"));
}
#[test]
fn patch_html_nonce_injects_into_bare_style() {
let html = "<style>body{color:red;}</style>";
let result = patch_html_nonce(html, "fresh-nonce");
assert!(result.contains(r#"<style nonce="fresh-nonce">"#));
}
#[test]
fn patch_html_nonce_injects_into_bare_script() {
let html = "<script>console.log(1);</script>";
let result = patch_html_nonce(html, "abc");
assert!(result.contains(r#"<script nonce="abc">"#));
}
// ── is_html_report_file / find_html_report_in_dir / find_html_report_in_tree ──
#[test]
fn is_html_report_file_result_html_matches() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("result_20240101.html");
std::fs::write(&path, b"<html></html>").unwrap();
assert!(is_html_report_file(&path));
}
#[test]
fn is_html_report_file_report_html_matches() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("report_abc.html");
std::fs::write(&path, b"<html></html>").unwrap();
assert!(is_html_report_file(&path));
}
#[test]
fn is_html_report_file_index_html_does_not_match() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("index.html");
std::fs::write(&path, b"<html></html>").unwrap();
assert!(!is_html_report_file(&path));
}
#[test]
fn is_html_report_file_nonexistent_returns_false() {
assert!(!is_html_report_file(Path::new(
"/nonexistent/result_xyz.html"
)));
}
#[test]
fn find_html_report_in_dir_finds_result_html() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("result_xyz.html"), b"<html></html>").unwrap();
assert!(find_html_report_in_dir(dir.path()).is_some());
}
#[test]
fn find_html_report_in_dir_empty_returns_none() {
let dir = tempfile::tempdir().unwrap();
assert!(find_html_report_in_dir(dir.path()).is_none());
}
#[test]
fn find_html_report_in_tree_finds_in_subdir() {
let dir = tempfile::tempdir().unwrap();
let subdir = dir.path().join("run-001");
std::fs::create_dir_all(&subdir).unwrap();
std::fs::write(subdir.join("result_abc.html"), b"<html></html>").unwrap();
assert!(find_html_report_in_tree(dir.path()).is_some());
}
// ── derive_project_label ──────────────────────────────────────────────────
#[test]
fn derive_project_label_with_git_repo_and_ref() {
let label = derive_project_label(
Some("https://github.com/owner/my-repo.git"),
Some("main"),
"/fallback/path",
);
assert!(!label.is_empty(), "label must not be empty");
assert!(
label.contains("my") || label.contains("repo"),
"got: {label}"
);
}
#[test]
fn derive_project_label_fallback_to_path() {
let label = derive_project_label(None, None, "/path/to/myproject");
assert_eq!(label, "myproject");
}
#[test]
fn derive_project_label_empty_git_fields_use_path() {
let label = derive_project_label(Some(""), Some(""), "/home/user/cool-app");
assert_eq!(label, "cool-app");
}
// ── derive_file_stem ──────────────────────────────────────────────────────
#[test]
fn derive_file_stem_with_commit_appends_sha() {
assert_eq!(
derive_file_stem("myproject", Some("a1b2c3")),
"myproject_a1b2c3"
);
}
#[test]
fn derive_file_stem_without_commit_returns_label() {
assert_eq!(derive_file_stem("myproject", None), "myproject");
}
#[test]
fn derive_file_stem_empty_commit_returns_label() {
assert_eq!(derive_file_stem("myproject", Some("")), "myproject");
}
// ── split_patterns ────────────────────────────────────────────────────────
#[test]
fn split_patterns_none_is_empty() {
assert!(split_patterns(None).is_empty());
}
#[test]
fn split_patterns_empty_string_is_empty() {
assert!(split_patterns(Some("")).is_empty());
}
#[test]
fn split_patterns_comma_separated() {
assert_eq!(
split_patterns(Some("foo,bar,baz")),
vec!["foo", "bar", "baz"]
);
}
#[test]
fn split_patterns_newline_separated() {
assert_eq!(
split_patterns(Some("foo\nbar\nbaz")),
vec!["foo", "bar", "baz"]
);
}
#[test]
fn split_patterns_trims_whitespace() {
assert_eq!(split_patterns(Some(" foo , bar ")), vec!["foo", "bar"]);
}
// ── make_git_label ────────────────────────────────────────────────────────
#[test]
fn make_git_label_empty_repo_empty_result() {
assert_eq!(make_git_label("", "main"), "");
}
#[test]
fn make_git_label_empty_ref_empty_result() {
assert_eq!(make_git_label("https://github.com/owner/repo", ""), "");
}
#[test]
fn make_git_label_basic_format() {
assert_eq!(
make_git_label("https://github.com/owner/my-repo.git", "main"),
"my-repo_at_main_sloc"
);
}
#[test]
fn make_git_label_slash_in_ref_replaced() {
let label = make_git_label("https://example.com/repo.git", "feature/my-branch");
assert!(
!label.contains('/'),
"slash in ref must be replaced: {label}"
);
}
// ── format_dir_size ───────────────────────────────────────────────────────
#[test]
fn format_dir_size_bytes() {
assert_eq!(format_dir_size(500), "500 B");
}
#[test]
fn format_dir_size_kilobytes() {
assert_eq!(format_dir_size(2048), "2 KB");
}
#[test]
fn format_dir_size_megabytes() {
assert!(format_dir_size(5 * 1_048_576).contains("MB"));
}
#[test]
fn format_dir_size_gigabytes() {
assert!(format_dir_size(2 * 1_073_741_824).contains("GB"));
}
#[test]
fn format_dir_size_zero() {
assert_eq!(format_dir_size(0), "0 B");
}
// ── civil_from_days ───────────────────────────────────────────────────────
#[test]
fn civil_from_days_epoch() {
assert_eq!(civil_from_days(0), (1970, 1, 1));
}
#[test]
fn civil_from_days_one_year_later() {
assert_eq!(civil_from_days(365), (1971, 1, 1));
}
#[test]
fn civil_from_days_31_days_is_feb_1_1970() {
assert_eq!(civil_from_days(31), (1970, 2, 1));
}
// ── format_system_time ────────────────────────────────────────────────────
#[test]
fn format_system_time_unix_epoch_formats_correctly() {
assert_eq!(format_system_time(UNIX_EPOCH), "1970-01-01 00:00");
}
#[test]
fn format_system_time_31_days_after_epoch() {
let t = UNIX_EPOCH + Duration::from_hours(744);
assert_eq!(format_system_time(t), "1970-02-01 00:00");
}
#[test]
fn format_system_time_before_epoch_returns_dash() {
if let Some(before) = UNIX_EPOCH.checked_sub(Duration::from_secs(1)) {
assert_eq!(format_system_time(before), "-");
}
}
// ── detect_language_name ──────────────────────────────────────────────────
#[test]
fn detect_language_name_dot_c() {
assert_eq!(detect_language_name("main.c"), Some("C"));
}
#[test]
fn detect_language_name_dot_h() {
assert_eq!(detect_language_name("defs.h"), Some("C"));
}
#[test]
fn detect_language_name_dot_cpp() {
assert_eq!(detect_language_name("algo.cpp"), Some("C++"));
}
#[test]
fn detect_language_name_dot_py() {
assert_eq!(detect_language_name("script.py"), Some("Python"));
}
#[test]
fn detect_language_name_dot_ps1() {
assert_eq!(detect_language_name("Deploy.ps1"), Some("PowerShell"));
}
#[test]
fn detect_language_name_dot_cs() {
assert_eq!(detect_language_name("Program.cs"), Some("C#"));
}
#[test]
fn detect_language_name_dot_sh() {
assert_eq!(detect_language_name("run.sh"), Some("Shell"));
}
#[test]
fn detect_language_name_unknown_txt() {
assert_eq!(detect_language_name("notes.txt"), None);
}
// ── language_icon_file ────────────────────────────────────────────────────
#[test]
fn language_icon_file_c() {
assert_eq!(language_icon_file("C"), Some("c.png"));
}
#[test]
fn language_icon_file_python() {
assert_eq!(language_icon_file("Python"), Some("python.png"));
}
#[test]
fn language_icon_file_dockerfile() {
assert_eq!(language_icon_file("Dockerfile"), Some("docker.png"));
}
#[test]
fn language_icon_file_rust_is_none() {
assert!(language_icon_file("Rust").is_none());
}
#[test]
fn language_icon_file_unknown_is_none() {
assert!(language_icon_file("Fortran").is_none());
}
// ── language_inline_svg ───────────────────────────────────────────────────
#[test]
fn language_inline_svg_rust_is_svg() {
let svg = language_inline_svg("Rust").unwrap();
assert!(svg.starts_with("<svg"));
}
#[test]
fn language_inline_svg_typescript_is_some() {
assert!(language_inline_svg("TypeScript").is_some());
}
#[test]
fn language_inline_svg_unknown_is_none() {
assert!(language_inline_svg("Fortran").is_none());
}
// ── classify_preview_file ─────────────────────────────────────────────────
#[test]
fn classify_preview_file_c_supported() {
assert!(matches!(
classify_preview_file("main.c"),
PreviewKind::Supported
));
}
#[test]
fn classify_preview_file_python_supported() {
assert!(matches!(
classify_preview_file("script.py"),
PreviewKind::Supported
));
}
#[test]
fn classify_preview_file_png_skipped() {
assert!(matches!(
classify_preview_file("image.png"),
PreviewKind::Skipped
));
}
#[test]
fn classify_preview_file_zip_skipped() {
assert!(matches!(
classify_preview_file("archive.zip"),
PreviewKind::Skipped
));
}
#[test]
fn classify_preview_file_min_js_skipped() {
assert!(matches!(
classify_preview_file("bundle.min.js"),
PreviewKind::Skipped
));
}
#[test]
fn classify_preview_file_rs_unsupported() {
assert!(matches!(
classify_preview_file("main.rs"),
PreviewKind::Unsupported
));
}
// ── preview_relative_path ─────────────────────────────────────────────────
#[test]
fn preview_relative_path_strips_root() {
let root = PathBuf::from("/project");
let path = PathBuf::from("/project/src/main.c");
assert_eq!(preview_relative_path(&root, &path), "src/main.c");
}
#[test]
fn preview_relative_path_unrooted_includes_filename() {
let root = PathBuf::from("/other");
let path = PathBuf::from("/project/src/main.c");
let result = preview_relative_path(&root, &path);
assert!(result.contains("main.c"));
}
#[test]
fn preview_relative_path_uses_forward_slashes() {
let root = PathBuf::from("/project");
let path = PathBuf::from("/project/a/b/c.py");
assert!(!preview_relative_path(&root, &path).contains('\\'));
}
// ── wildcard_match ────────────────────────────────────────────────────────
#[test]
fn wildcard_match_exact_equal() {
assert!(wildcard_match("foo", "foo"));
}
#[test]
fn wildcard_match_exact_mismatch() {
assert!(!wildcard_match("foo", "bar"));
}
#[test]
fn wildcard_match_star_suffix() {
assert!(wildcard_match("*.rs", "main.rs"));
}
#[test]
fn wildcard_match_star_middle_requires_suffix() {
assert!(!wildcard_match("a*b", "ac"));
}
#[test]
fn wildcard_match_question_mark_single_char() {
assert!(wildcard_match("f?o", "foo"));
}
#[test]
fn wildcard_match_double_star_nested() {
assert!(wildcard_match("src/**", "src/a/b/c.rs"));
}
#[test]
fn wildcard_match_star_directory_entry() {
assert!(wildcard_match("vendor/*", "vendor/crate"));
}
#[test]
fn wildcard_match_no_cross_prefix() {
assert!(!wildcard_match("src/*.rs", "tests/foo.rs"));
}
// ── should_skip_preview_directory ────────────────────────────────────────
#[test]
fn should_skip_empty_relative_is_false() {
assert!(!should_skip_preview_directory("", &["vendor".to_string()]));
}
#[test]
fn should_skip_matching_pattern() {
assert!(should_skip_preview_directory(
"vendor",
&["vendor".to_string()]
));
}
#[test]
fn should_skip_non_matching() {
assert!(!should_skip_preview_directory(
"src",
&["vendor".to_string()]
));
}
#[test]
fn should_skip_wildcard_prefix() {
assert!(should_skip_preview_directory(
"target/debug",
&["target*".to_string()]
));
}
// ── should_include_preview_file ───────────────────────────────────────────
#[test]
fn should_include_empty_relative_always_true() {
assert!(should_include_preview_file("", &[], &[]));
}
#[test]
fn should_include_no_patterns_includes_all() {
assert!(should_include_preview_file("src/main.c", &[], &[]));
}
#[test]
fn should_include_excluded_by_pattern() {
assert!(!should_include_preview_file(
"vendor/lib.c",
&[],
&["vendor/*".to_string()]
));
}
#[test]
fn should_include_include_pattern_filters() {
assert!(!should_include_preview_file(
"tests/test_foo.c",
&["src/*".to_string()],
&[]
));
}
// ── escape_html ───────────────────────────────────────────────────────────
#[test]
fn escape_html_ampersand() {
assert_eq!(escape_html("a&b"), "a&b");
}
#[test]
fn escape_html_angle_brackets() {
assert_eq!(escape_html("<br>"), "<br>");
}
#[test]
fn escape_html_double_quote() {
assert_eq!(escape_html(r#"say "hello""#), "say "hello"");
}
#[test]
fn escape_html_single_quote() {
assert_eq!(escape_html("it's"), "it's");
}
#[test]
fn escape_html_plain_text_unchanged() {
assert_eq!(escape_html("hello world"), "hello world");
}
// ── sum_added / removed / unmodified code lines ───────────────────────────
fn make_mixed_scan_comparison() -> sloc_core::ScanComparison {
sloc_core::ScanComparison {
summary: sloc_core::SummaryDelta {
baseline_run_id: "base".to_string(),
current_run_id: "curr".to_string(),
baseline_timestamp: chrono::Utc::now(),
current_timestamp: chrono::Utc::now(),
baseline_files: 4,
current_files: 4,
files_analyzed_delta: 0,
baseline_code: 330,
current_code: 400,
code_lines_delta: 70,
baseline_comments: 0,
current_comments: 0,
comment_lines_delta: 0,
blank_lines_delta: 0,
total_lines_delta: 70,
coverage_lines_hit_delta: None,
coverage_line_pct_delta: None,
baseline_coverage_line_pct: None,
current_coverage_line_pct: None,
},
file_deltas: vec![
sloc_core::FileDelta {
relative_path: "added.rs".to_string(),
language: Some("Rust".to_string()),
status: FileChangeStatus::Added,
baseline_code: 0,
current_code: 100,
code_delta: 100,
baseline_comment: 0,
current_comment: 0,
comment_delta: 0,
baseline_blank: 0,
current_blank: 0,
blank_delta: 0,
total_delta: 100,
},
sloc_core::FileDelta {
relative_path: "removed.rs".to_string(),
language: Some("Rust".to_string()),
status: FileChangeStatus::Removed,
baseline_code: 50,
current_code: 0,
code_delta: -50,
baseline_comment: 0,
current_comment: 0,
comment_delta: 0,
baseline_blank: 0,
current_blank: 0,
blank_delta: 0,
total_delta: -50,
},
sloc_core::FileDelta {
relative_path: "modified.rs".to_string(),
language: Some("Rust".to_string()),
status: FileChangeStatus::Modified,
baseline_code: 80,
current_code: 100,
code_delta: 20,
baseline_comment: 0,
current_comment: 0,
comment_delta: 0,
baseline_blank: 0,
current_blank: 0,
blank_delta: 0,
total_delta: 20,
},
sloc_core::FileDelta {
relative_path: "unchanged.rs".to_string(),
language: Some("Rust".to_string()),
status: FileChangeStatus::Unchanged,
baseline_code: 200,
current_code: 200,
code_delta: 0,
baseline_comment: 0,
current_comment: 0,
comment_delta: 0,
baseline_blank: 0,
current_blank: 0,
blank_delta: 0,
total_delta: 0,
},
],
files_added: 1,
files_removed: 1,
files_modified: 1,
files_unchanged: 1,
}
}
#[test]
fn sum_added_counts_added_and_positive_modified() {
let cmp = make_mixed_scan_comparison();
assert_eq!(sum_added_code_lines(&cmp), 120);
}
#[test]
fn sum_removed_counts_removed_baseline() {
let cmp = make_mixed_scan_comparison();
assert_eq!(sum_removed_code_lines(&cmp), 50);
}
#[test]
fn sum_unmodified_counts_unchanged_files() {
let cmp = make_mixed_scan_comparison();
assert_eq!(sum_unmodified_code_lines(&cmp), 200);
}
// ── detect_coverage_tool ──────────────────────────────────────────────────
#[test]
fn detect_coverage_tool_rust_project() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("Cargo.toml"), b"[package]").unwrap();
let (tool, cmd) = detect_coverage_tool(dir.path());
assert_eq!(tool, Some("cargo-llvm-cov"));
assert!(cmd.is_some());
}
#[test]
fn detect_coverage_tool_java_gradle() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("build.gradle"), b"apply plugin: 'java'").unwrap();
let (tool, _) = detect_coverage_tool(dir.path());
assert_eq!(tool, Some("jacoco"));
}
#[test]
fn detect_coverage_tool_python_pyproject() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("pyproject.toml"), b"[tool.poetry]").unwrap();
let (tool, _) = detect_coverage_tool(dir.path());
assert_eq!(tool, Some("pytest-cov"));
}
#[test]
fn detect_coverage_tool_unknown_project() {
let dir = tempfile::tempdir().unwrap();
let (tool, cmd) = detect_coverage_tool(dir.path());
assert!(tool.is_none() && cmd.is_none());
}
// ── sanitize_path_str / display_path ─────────────────────────────────────
#[test]
fn sanitize_path_str_unc_drive_stripped() {
assert_eq!(sanitize_path_str("//?/C:/Users/user"), "C:/Users/user");
}
#[test]
fn sanitize_path_str_unc_network_stripped() {
assert_eq!(sanitize_path_str("//?/UNC/server/share"), "//server/share");
}
#[test]
fn sanitize_path_str_plain_path_unchanged() {
assert_eq!(
sanitize_path_str("/home/user/project"),
"/home/user/project"
);
}
#[test]
fn display_path_plain_linux_unchanged() {
assert_eq!(
display_path(Path::new("/home/user/project")),
"/home/user/project"
);
}
#[test]
fn display_path_unc_drive_stripped() {
let result = display_path(Path::new(r"\\?\C:\Users\user"));
assert_eq!(result, r"C:\Users\user");
}
#[test]
fn display_path_unc_network_stripped() {
let result = display_path(Path::new(r"\\?\UNC\server\share"));
assert_eq!(result, r"\\server\share");
}
}
#[cfg(test)]
mod coverage_boost_unit_tests {
use super::*;
use std::path::{Path, PathBuf};
// Both scenarios live in one test (sequential, under a Tokio runtime) because
// load_runtime_security_config spawns a pruning task and mutates process-global
// env vars — parallel sub-tests would race on both.
#[tokio::test]
async fn runtime_security_config_scenarios() {
std::env::remove_var("SLOC_API_KEYS");
std::env::remove_var("SLOC_API_KEY");
std::env::remove_var("SLOC_TLS_CERT");
std::env::remove_var("SLOC_TLS_KEY");
std::env::remove_var("SLOC_TRUST_PROXY");
std::env::remove_var("SLOC_TRUSTED_PROXY_IPS");
let cfg = load_runtime_security_config(false);
assert!(cfg.api_keys.is_empty());
assert!(!cfg.tls_enabled);
assert!(!cfg.trust_proxy);
std::env::set_var("SLOC_API_KEYS", "alpha, beta ,");
std::env::set_var("SLOC_TRUST_PROXY", "1");
std::env::set_var("SLOC_TRUSTED_PROXY_IPS", "127.0.0.1, 10.0.0.2");
std::env::set_var("SLOC_RATE_LIMIT", "250");
std::env::set_var("SLOC_AUTH_LOCKOUT_FAILS", "5");
std::env::set_var("SLOC_AUTH_LOCKOUT_SECS", "60");
let cfg = load_runtime_security_config(true);
assert_eq!(cfg.api_keys.len(), 2, "two non-empty keys parsed");
assert!(cfg.trust_proxy);
assert_eq!(cfg.trusted_proxy_ips.len(), 2);
std::env::remove_var("SLOC_API_KEYS");
std::env::remove_var("SLOC_TRUST_PROXY");
std::env::remove_var("SLOC_TRUSTED_PROXY_IPS");
std::env::remove_var("SLOC_RATE_LIMIT");
std::env::remove_var("SLOC_AUTH_LOCKOUT_FAILS");
std::env::remove_var("SLOC_AUTH_LOCKOUT_SECS");
}
#[test]
fn cors_layer_builds_both_modes() {
let _ = build_cors_layer(true);
let _ = build_cors_layer(false);
}
#[test]
fn primary_lan_ip_callable() {
// May be Some or None depending on the host; both are valid.
let _ = primary_lan_ip();
}
#[test]
fn safe_redirect_allows_relative_rejects_absolute() {
assert_eq!(safe_redirect("/view-reports"), "/view-reports");
assert_eq!(safe_redirect("https://evil.example/x"), "/");
assert_eq!(safe_redirect("javascript:alert(1)"), "/");
assert_eq!(default_redirect(), "/view-reports");
}
#[test]
fn tarball_size_caps_env_override() {
std::env::set_var("SLOC_MAX_TARBALL_MB", "1");
std::env::set_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB", "2");
let (c, d) = parse_tarball_size_caps();
assert_eq!(c, 1024 * 1024);
assert_eq!(d, 2 * 1024 * 1024);
std::env::remove_var("SLOC_MAX_TARBALL_MB");
std::env::remove_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB");
let (c2, _) = parse_tarball_size_caps();
assert_eq!(c2, 2048 * 1024 * 1024, "default 2048 MB");
}
#[test]
fn upload_path_helpers() {
let base = upload_base_dir();
let staged = upload_staging_path("abc123");
assert!(staged.starts_with(&base));
assert!(
is_upload_tmp_path(&staged),
"staging path is an upload tmp path"
);
assert!(!is_upload_tmp_path(Path::new("/etc/passwd")));
}
#[test]
fn git_clones_dir_env_override() {
std::env::remove_var("SLOC_GIT_CLONES_DIR");
let def = resolve_git_clones_dir(Path::new("/out"));
assert_eq!(def, PathBuf::from("/out").join("git-clones"));
std::env::set_var("SLOC_GIT_CLONES_DIR", "/custom/clones");
assert_eq!(
resolve_git_clones_dir(Path::new("/out")),
PathBuf::from("/custom/clones")
);
std::env::remove_var("SLOC_GIT_CLONES_DIR");
}
#[test]
fn html_report_file_detection() {
let dir = std::env::temp_dir().join("sloc_html_detect");
let _ = std::fs::create_dir_all(&dir);
let good = dir.join("report_x.html");
std::fs::write(&good, "<html></html>").unwrap();
let bad = dir.join("notes.txt");
std::fs::write(&bad, "x").unwrap();
assert!(is_html_report_file(&good));
assert!(!is_html_report_file(&bad));
assert!(find_html_report_in_dir(&dir).is_some());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn multi_delta_class_and_format() {
assert_eq!(multi_delta_class(5), "pos");
assert_eq!(multi_delta_class(-5), "neg");
assert_eq!(multi_delta_class(0), "zero");
assert_eq!(multi_fmt_delta(3), "+3");
assert_eq!(multi_fmt_delta(-3), "-3");
assert_eq!(multi_fmt_delta(0), "0");
}
#[test]
fn git_clone_dest_sanitizes() {
let dest = git_clone_dest("https://github.com/org/repo.git", Path::new("/clones"));
assert!(dest.starts_with("/clones"));
let name = dest.file_name().unwrap().to_str().unwrap();
assert!(name
.chars()
.all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.')));
}
}