// 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 confluence;
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,
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, MixedLinePolicy};
use sloc_git::ScheduleStore;
#[derive(Clone)]
struct CspNonce(String);
static CHART_JS: &[u8] = include_bytes!("../static/chart.umd.min.js");
use sloc_core::{
analyze, compute_delta, read_json, AnalysisRun, FileChangeStatus, RegistryEntry, ScanRegistry,
ScanSummarySnapshot, SummaryTotals, WatchedDirsStore,
};
use sloc_report::{render_html, render_sub_report_html, write_pdf_from_html};
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(all(target_os = "windows", feature = "native-dialog"))]
#[allow(clippy::upper_case_acronyms)]
mod win_dialog_focus {
use std::mem::size_of;
type HWND = *mut core::ffi::c_void;
type DWORD = u32;
type UINT = u32;
type BOOL = i32;
// Mirror of FLASHWINFO from winuser.h — field names kept in PascalCase to
// match the Win32 ABI layout exactly; the #[allow] suppresses the Rust
// naming lint for this one struct.
#[repr(C)]
#[allow(non_snake_case)]
struct FLASHWINFO {
cbSize: UINT,
hwnd: HWND,
dwFlags: DWORD,
uCount: UINT,
dwTimeout: DWORD,
}
const FLASHW_ALL: DWORD = 0x3;
const FLASHW_TIMERNOFG: DWORD = 0xC;
#[link(name = "user32")]
extern "system" {
fn GetForegroundWindow() -> HWND;
fn SetForegroundWindow(hWnd: HWND) -> BOOL;
fn BringWindowToTop(hWnd: HWND) -> BOOL;
fn GetWindowThreadProcessId(hWnd: HWND, lpdwProcessId: *mut DWORD) -> DWORD;
fn AttachThreadInput(idAttach: DWORD, idAttachTo: DWORD, fAttach: BOOL) -> BOOL;
fn FlashWindowEx(pfwi: *const FLASHWINFO) -> BOOL;
fn FindWindowW(lpClassName: *const u16, lpWindowName: *const u16) -> HWND;
}
#[link(name = "kernel32")]
extern "system" {
fn GetCurrentThreadId() -> DWORD;
}
/// 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.
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`.
pub fn detach_from_foreground(fg_tid: DWORD) {
if fg_tid == 0 {
return;
}
unsafe {
AttachThreadInput(GetCurrentThreadId(), fg_tid, 0);
}
}
/// 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.
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.
struct IpRateLimiter {
window: Duration,
max_requests: usize,
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 {
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)]
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
}
}
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));
}
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)
}
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())
})
}
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);
}
}
});
}
}
/// 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,
}
/// 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>,
},
/// `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)]
struct AppState {
base_config: AppConfig,
artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
async_runs: Arc<Mutex<HashMap<String, AsyncRunState>>>,
registry: Arc<Mutex<ScanRegistry>>,
registry_path: PathBuf,
analyze_semaphore: Arc<tokio::sync::Semaphore>,
server_mode: bool,
tls_enabled: bool,
api_keys: Vec<secrecy::Secret<String>>,
rate_limiter: Arc<IpRateLimiter>,
trust_proxy: bool,
/// Directory where remote repositories are cloned for git-browser scans.
git_clones_dir: PathBuf,
/// Persisted list of webhook / poll schedules.
schedules: Arc<Mutex<ScheduleStore>>,
schedules_path: PathBuf,
/// Named scan profiles saved by the user via the web UI.
scan_profiles: Arc<Mutex<ScanProfileStore>>,
scan_profiles_path: PathBuf,
sessions: Arc<std::sync::Mutex<HashMap<String, Instant>>>,
/// Persisted Confluence integration settings.
confluence: Arc<Mutex<confluence::ConfluenceConfigStore>>,
confluence_path: PathBuf,
/// Directories the user has pinned for auto-scanning of external reports.
watched_dirs: Arc<Mutex<WatchedDirsStore>>,
watched_dirs_path: PathBuf,
}
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>,
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 {
// NOSONAR(rust:S3776)
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("/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("/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))
// ── 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#webhooks") }),
)
.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),
)
// ── REST API reference page ────────────────────────────────────────────
.route("/api-docs", get(api_docs_handler))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_api_key,
));
protected
.route("/healthz", get(healthz))
.route("/badge/{metric}", get(badge_handler))
.route("/static/chart.js", get(chart_js_handler))
.route("/auth/login", get(auth_login_get))
.route("/auth/login", post(auth_login_post))
// Webhook receivers are public (no API-key auth) — they use per-schedule HMAC secrets.
.route("/webhooks/github", post(git_webhook::handle_github_webhook))
.route("/webhooks/gitlab", post(git_webhook::handle_gitlab_webhook))
.route(
"/webhooks/bitbucket",
post(git_webhook::handle_bitbucket_webhook),
)
.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 {
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: vec![],
rate_limiter: Arc::new(IpRateLimiter::new(
Duration::from_mins(1),
600,
10,
Duration::from_hours(1),
)),
trust_proxy: false,
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"),
};
build_router(state)
}
/// # 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).
// The function coordinates TLS setup, router construction, and async listener setup in one
// place; splitting it further would require passing many state values across function boundaries.
#[allow(clippy::too_many_lines)]
pub async fn serve(config: AppConfig) -> Result<()> {
// NOSONAR(rust:S3776)
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 api_keys: Vec<secrecy::Secret<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::Secret::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");
if trust_proxy {
println!(
"NOTE: SLOC_TRUST_PROXY=1 — X-Forwarded-For header is trusted for rate limiting. \
Only set this when oxide-sloc is behind a trusted reverse proxy."
);
}
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);
// 600 req/min per IP across all routes (10/sec — suits local/air-gapped use).
let rate_limiter = Arc::new(IpRateLimiter::new(
Duration::from_mins(1),
600,
auth_lockout_threshold,
Duration::from_secs(auth_lockout_secs),
));
IpRateLimiter::spawn_pruning_task(Arc::clone(&rate_limiter));
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 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,
api_keys,
rate_limiter,
trust_proxy,
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,
};
restart_poll_schedules(&state).await;
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 tls_enabled {
let cert_path = tls_cert.expect("tls_enabled guarantees SLOC_TLS_CERT is Some");
let key_path = 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_pemfile::{certs, private_key};
use std::io::BufReader;
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<_> = certs(&mut BufReader::new(cert_bytes.as_slice()))
.collect::<std::result::Result<_, _>>()
.context("failed to parse TLS certificates")?;
let key = private_key(&mut BufReader::new(key_bytes.as_slice()))
.context("failed to parse TLS private key")?
.ok_or_else(|| anyhow::anyhow!("no private key found in {key_path}"))?;
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}");
}
});
}
}
}
}
#[allow(clippy::too_many_lines)] // middleware with multi-path auth logic; extraction is impractical
async fn require_api_key(
// NOSONAR(rust:S3776)
State(state): State<AppState>,
req: Request<Body>,
next: Next,
) -> Response {
if state.api_keys.is_empty() {
return next.run(req).await;
}
let keys = &state.api_keys;
let peer_ip = req
.extensions()
.get::<axum::extract::ConnectInfo<SocketAddr>>()
.map_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), |c| c.0.ip());
// Collect credentials from all three sources: Bearer header, X-API-Key, session cookie.
let auth_header = req
.headers()
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(str::to_owned);
let x_api_key = req
.headers()
.get("X-API-Key")
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
let session_cookie = req
.headers()
.get(header::COOKIE)
.and_then(|v| v.to_str().ok())
.and_then(extract_session_cookie)
.map(str::to_owned);
let session_valid = session_cookie.as_deref().is_some_and(|tok| {
let now = Instant::now();
let mut sessions = state
.sessions
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(&expiry) = sessions.get(tok) {
if now < expiry {
return true;
}
sessions.remove(tok);
}
false
});
let any_credential_provided =
auth_header.is_some() || x_api_key.is_some() || session_cookie.is_some();
let valid = session_valid
|| [&auth_header, &x_api_key]
.iter()
.filter_map(|o| o.as_deref())
.any(|k| {
keys.iter().any(|expected| {
use secrecy::ExposeSecret;
ct_eq(k, expected.expose_secret())
})
});
if valid {
return next.run(req).await;
}
if state.rate_limiter.is_auth_locked_out(peer_ip) {
tracing::warn!(event = "auth_lockout", peer_addr = %peer_ip,
"Authentication locked out after repeated failures");
let remaining = state.rate_limiter.auth_lockout_remaining_secs(peer_ip);
let retry_after = HeaderValue::from_str(&remaining.to_string())
.unwrap_or(HeaderValue::from_static("3600"));
if is_browser_request(&req) {
let minutes = remaining.div_ceil(60).max(1);
let s = if minutes == 1 { "" } else { "s" };
let body = format!(
r#"<!doctype html><html><head><meta charset="utf-8">
<title>Locked Out — OxideSLOC</title>
<style>body{{font-family:system-ui,sans-serif;max-width:520px;margin:80px auto;padding:0 24px;color:#2f241c}}
h1{{color:#b85d33}}p{{line-height:1.6}}code{{background:#f3e9e0;padding:2px 6px;border-radius:4px}}</style>
</head><body>
<h1>Too many failed sign-in attempts</h1>
<p>Access from your IP is temporarily locked. Lockout expires in approximately
<strong>{minutes} minute{s}</strong>.</p>
<p>To clear immediately, restart the server.</p>
<p>For trusted LAN testing, leave <code>SLOC_API_KEY</code> unset, or raise the
threshold via <code>SLOC_AUTH_LOCKOUT_FAILS</code> / <code>SLOC_AUTH_LOCKOUT_SECS</code>.</p>
</body></html>"#
);
let mut resp = (StatusCode::TOO_MANY_REQUESTS, Html(body)).into_response();
resp.headers_mut().insert(header::RETRY_AFTER, retry_after);
return resp;
}
let mut resp = (
StatusCode::TOO_MANY_REQUESTS,
format!("429 Too Many Requests — locked out, retry in {remaining}s\n"),
)
.into_response();
resp.headers_mut().insert(header::RETRY_AFTER, retry_after);
return resp;
}
if any_credential_provided {
// A credential was supplied but didn't match — record the failure.
state.rate_limiter.record_auth_failure(peer_ip);
let path = req.uri().path().to_owned();
tracing::warn!(event = "auth_failure", peer_addr = %peer_ip, path = %path,
"API key authentication failed");
return (
StatusCode::UNAUTHORIZED,
[(header::WWW_AUTHENTICATE, "Bearer realm=\"oxide-sloc\"")],
"401 Unauthorized\n",
)
.into_response();
}
// No credential supplied at all. Redirect browsers to the login form; return
// a plain 401 for API clients (without recording a failure — unauthenticated
// browser page loads should not burn the lockout counter).
if is_browser_request(&req) {
let next_path = req.uri().path_and_query().map_or("/", |pq| pq.as_str());
let login_url = format!("/auth/login?next={}", urlencode_path(next_path));
let location = HeaderValue::from_str(&login_url)
.unwrap_or_else(|_| HeaderValue::from_static("/auth/login"));
let mut resp = StatusCode::FOUND.into_response();
resp.headers_mut().insert(header::LOCATION, location);
return resp;
}
(
StatusCode::UNAUTHORIZED,
[(header::WWW_AUTHENTICATE, "Bearer realm=\"oxide-sloc\"")],
"401 Unauthorized\n",
)
.into_response()
}
fn ct_eq(a: &str, b: &str) -> bool {
use subtle::ConstantTimeEq;
a.as_bytes().ct_eq(b.as_bytes()).into()
}
fn extract_session_cookie(cookie_header: &str) -> Option<&str> {
cookie_header.split(';').find_map(|pair| {
let pair = pair.trim();
let (k, v) = pair.split_once('=')?;
if k.trim() == "sloc_session" {
Some(v.trim())
} else {
None
}
})
}
fn is_browser_request(req: &Request<Body>) -> bool {
req.headers()
.get(header::ACCEPT)
.and_then(|v| v.to_str().ok())
.is_some_and(|a| a.contains("text/html"))
}
fn urlencode_path(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z'
| b'a'..=b'z'
| b'0'..=b'9'
| b'-'
| b'_'
| b'.'
| b'~'
| b'/'
| b'?'
| b'='
| b'&'
| b'#' => {
out.push(b as char);
}
_ => {
use std::fmt::Write as _;
write!(&mut out, "%{b:02X}").ok();
}
}
}
out
}
// ── Login form handlers ────────────────────────────────────────────────────────
#[derive(serde::Deserialize)]
struct LoginQuery {
next: Option<String>,
error: Option<String>,
}
#[derive(serde::Deserialize)]
struct LoginFormData {
key: String,
next: Option<String>,
}
async fn auth_login_get(
State(state): State<AppState>,
Query(query): Query<LoginQuery>,
axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
) -> Response {
if state.api_keys.is_empty() {
let mut resp = StatusCode::FOUND.into_response();
resp.headers_mut()
.insert(header::LOCATION, HeaderValue::from_static("/"));
return resp;
}
let has_error = query.error.as_deref() == Some("1");
let next_url = query.next.unwrap_or_default();
let lockout_threshold = state.rate_limiter.auth_lockout_threshold;
Html(
LoginTemplate {
csp_nonce,
has_error,
next_url,
lockout_threshold,
}
.render()
.unwrap_or_else(|e| format!("<pre>Template error: {e}</pre>")),
)
.into_response()
}
async fn auth_login_post(
State(state): State<AppState>,
axum::extract::ConnectInfo(peer_addr): axum::extract::ConnectInfo<SocketAddr>,
Form(form): Form<LoginFormData>,
) -> Response {
let peer_ip = peer_addr.ip();
let next_url = form
.next
.as_deref()
.filter(|s| !s.is_empty())
.unwrap_or("/");
let safe_next = if next_url.starts_with('/') && !next_url.starts_with("//") {
next_url
} else {
"/"
};
let valid = state.api_keys.iter().any(|expected| {
use secrecy::ExposeSecret;
ct_eq(&form.key, expected.expose_secret())
});
if valid {
const SESSION_SECS: u64 = 8 * 3600;
let session_id = uuid::Uuid::new_v4().to_string();
let expiry = Instant::now() + Duration::from_secs(SESSION_SECS);
state
.sessions
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(session_id.clone(), expiry);
let secure_flag = if state.tls_enabled { "; Secure" } else { "" };
let cookie_value = format!(
"sloc_session={session_id}; Path=/; HttpOnly; SameSite=Strict; Max-Age={SESSION_SECS}{secure_flag}",
);
let location =
HeaderValue::from_str(safe_next).unwrap_or_else(|_| HeaderValue::from_static("/"));
let cookie_hv = HeaderValue::from_str(&cookie_value)
.unwrap_or_else(|_| HeaderValue::from_static("sloc_session=; Path=/; HttpOnly"));
let mut resp = StatusCode::FOUND.into_response();
resp.headers_mut().insert(header::LOCATION, location);
resp.headers_mut().insert(header::SET_COOKIE, cookie_hv);
resp
} else {
state.rate_limiter.record_auth_failure(peer_ip);
tracing::warn!(event = "auth_failure", peer_addr = %peer_ip, path = "/auth/login",
"Login form authentication failed");
let error_url = format!("/auth/login?next={}&error=1", urlencode_path(safe_next));
let location = HeaderValue::from_str(&error_url)
.unwrap_or_else(|_| HeaderValue::from_static("/auth/login?error=1"));
let mut resp = StatusCode::FOUND.into_response();
resp.headers_mut().insert(header::LOCATION, location);
resp
}
}
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' 'nonce-{nonce}'; \
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 ip = req
.extensions()
.get::<axum::extract::ConnectInfo<SocketAddr>>()
.map(|c| c.0.ip())
.or_else(|| {
if state.trust_proxy {
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
}
})
.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 template = SplashTemplate {
csp_nonce,
server_mode: state.server_mode,
lan_ip,
port,
version: env!("CARGO_PKG_VERSION"),
};
Html(
template
.render()
.unwrap_or_else(|err| format!("<pre>{err}</pre>")),
)
}
async fn index(
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(),
generate_html: query.generate_html.as_deref() != Some("off"),
generate_pdf: query.generate_pdf.as_deref() == Some("on"),
};
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,
};
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_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",
)],
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>,
generate_html: Option<String>,
generate_pdf: Option<String>,
include_globs: Option<String>,
exclude_globs: Option<String>,
submodule_breakdown: Option<String>,
coverage_file: 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,
generate_html: bool,
generate_pdf: bool,
}
#[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>,
generate_html: Option<String>,
generate_pdf: 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();
}
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 {
StatusCode::NOT_FOUND.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();
}
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 {
StatusCode::NOT_FOUND.into_response()
}
#[derive(Deserialize)]
struct LocateReportForm {
file_path: 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()),
csp_nonce: csp_nonce.to_owned(),
}
.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,
},
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);
}
/// Validate the locate-report form: check extension, resolve the canonical path, enforce
/// server-mode root restriction, and extract the parent directory.
///
/// 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 file_ext = Path::new(file_path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
if file_ext != "html" {
return Err(locate_report_error(
"Only .html report files can be located via this form.",
csp_nonce,
));
}
let html_path = match fs::canonicalize(PathBuf::from(file_path)) {
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))
}
/// Return a non-sensitive path hint for error messages (empty in server mode).
fn locate_path_hint(server_mode: bool, path: &Path) -> String {
if server_mode {
String::new()
} else {
format!("\n\nFile: {}", path.display())
}
}
async fn locate_report_handler(
State(state): State<AppState>,
axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
Form(form): Form<LocateReportForm>,
) -> impl IntoResponse {
let (html_path, parent) = match validate_locate_request(&state, &form.file_path, &csp_nonce) {
Ok(v) => v,
Err(resp) => return resp,
};
let json_candidate = parent.join("result.json");
let mut reg = state.registry.lock().await;
// Find an existing entry whose output directory matches the selected file's parent.
let entry_idx = reg.entries.iter().position(|e| {
let json_match = e
.json_path
.as_ref()
.and_then(|p| p.parent())
.is_some_and(|p| p == parent);
let html_match = e
.html_path
.as_ref()
.and_then(|p| p.parent())
.is_some_and(|p| p == parent);
json_match || html_match
});
if let Some(idx) = entry_idx {
reg.entries[idx].html_path = Some(html_path);
let _ = reg.save(&state.registry_path);
return axum::response::Redirect::to("/view-reports?linked=1").into_response();
}
// No match — attempt to build an entry from an adjacent result.json.
if json_candidate.exists() {
match read_json(&json_candidate) {
Ok(run) => {
let entry = registry_entry_from_run(&run, json_candidate, html_path);
reg.add_entry(entry);
let _ = reg.save(&state.registry_path);
return axum::response::Redirect::to("/view-reports?linked=1").into_response();
}
Err(e) => {
let file_hint = locate_path_hint(state.server_mode, &json_candidate);
let err_detail = if state.server_mode {
String::new()
} else {
format!("\n\nError: {e}")
};
return locate_report_error(
format!(
"Could not link this report.\n\nA 'result.json' was found but could not \
be parsed — it may have been saved by an older version of OxideSLOC. \
Re-running the analysis will create a fresh, compatible \
record.{file_hint}{err_detail}"
),
&csp_nonce,
);
}
}
}
drop(reg);
let file_hint = locate_path_hint(state.server_mode, &html_path);
locate_report_error(
format!(
"Could not link this report.\n\nNo matching scan record was found, and no \
'result.json' was found in the same folder.{file_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(
// NOSONAR(rust:S3776)
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();
}
// Collect result*.json candidates: the folder itself and one level of subdirectories.
// Filenames use the pattern result_<project>_<commit>.json — match by prefix/suffix.
let mut candidates: Vec<PathBuf> = 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);
}
}
}
}
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 parent = match json_path.parent() {
Some(p) => p.to_path_buf(),
None => continue,
};
// Skip if this directory is already registered AND the artifact still exists on disk.
// A stale entry (file moved/deleted) must not block re-scanning the same directory.
let already = 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()))
});
if already {
continue;
}
// Find the first .html file in the same directory.
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 Ok(run) = read_json(&json_path) else {
continue;
};
let project_label = run.input_roots.first().map_or_else(
|| "Unknown Project".to_string(),
|r| sanitize_project_label(r),
);
let entry = 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,
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,
},
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(),
};
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,
}
#[allow(clippy::too_many_lines)] // scan relocation handler with inline HTML rendering
async fn relocate_scan_handler(
// NOSONAR(rust:S3776)
State(state): State<AppState>,
axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
Form(form): Form<RelocateScanForm>,
) -> impl IntoResponse {
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 {
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()),
csp_nonce: csp_nonce.clone(),
}
.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 missing_scan_relocate_response(
"Folder not found or path is invalid.",
&run_id,
form.folder_path.trim(),
&redirect_url,
false,
&csp_nonce,
);
}
};
if !folder.is_dir() {
return missing_scan_relocate_response(
"Selected path is not a directory.",
&run_id,
&folder.display().to_string(),
&redirect_url,
false,
&csp_nonce,
);
}
let json_candidates: Vec<PathBuf> = fs::read_dir(&folder)
.ok()
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|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"))
})
.collect();
if json_candidates.is_empty() {
return missing_scan_relocate_response(
&format!(
"No result JSON files found in the selected folder.\nSearched: {}",
folder.display()
),
&run_id,
&folder.display().to_string(),
&redirect_url,
false,
&csp_nonce,
);
}
let mut matched_json: Option<PathBuf> = None;
for candidate in &json_candidates {
if let Ok(run) = read_json(candidate) {
if run.tool.run_id == run_id {
matched_json = Some(candidate.clone());
break;
}
}
}
let Some(json_path) = matched_json else {
return missing_scan_relocate_response(
&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()
),
&run_id,
&folder.display().to_string(),
&redirect_url,
false,
&csp_nonce,
);
};
let html_path = fs::read_dir(&folder)
.ok()
.into_iter()
.flatten()
.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("html"))
});
let pdf_path = fs::read_dir(&folder)
.ok()
.into_iter()
.flatten()
.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("pdf"))
});
{
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);
}
let safe_redirect = if redirect_url.starts_with('/') && !redirect_url.starts_with("//") {
redirect_url
} else {
"/compare-scans".to_string()
};
axum::response::Redirect::to(&safe_redirect).into_response()
}
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(),
}
.render()
.unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
(StatusCode::NOT_FOUND, Html(html)).into_response()
}
// ── Watched-directory helpers ─────────────────────────────────────────────────
/// 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 candidates: Vec<PathBuf> = 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);
}
}
}
}
let mut linked = 0usize;
for json_path in candidates {
let parent = match json_path.parent() {
Some(p) => p.to_path_buf(),
None => continue,
};
let already = 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()))
});
if already {
continue;
}
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 Ok(run) = read_json(&json_path) else {
continue;
};
let project_label = run.input_roots.first().map_or_else(
|| "Unknown Project".to_string(),
|r| sanitize_project_label(r),
);
let entry = 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,
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,
},
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(),
};
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>,
}
async fn open_path_handler(
State(state): State<AppState>,
Query(query): Query<OpenPathQuery>,
) -> impl IntoResponse {
if state.server_mode {
return StatusCode::NOT_FOUND.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 fs::canonicalize(raw) {
Ok(canonical) if canonical.is_file() => match canonical.parent() {
Some(p) => p.to_path_buf(),
None => return (StatusCode::BAD_REQUEST, "path has no parent").into_response(),
},
Ok(canonical) if canonical.is_dir() => canonical,
Ok(_) => {
return (StatusCode::BAD_REQUEST, "path is not a file or directory").into_response()
}
Err(_) => {
// Path doesn't exist — find nearest existing ancestor directory.
let mut ancestor = std::path::Path::new(raw);
loop {
match ancestor.parent() {
Some(p) => {
ancestor = p;
if ancestor.is_dir() {
break;
}
}
None => {
return (StatusCode::BAD_REQUEST, "no existing ancestor found")
.into_response();
}
}
}
ancestor.to_path_buf()
}
};
#[cfg(target_os = "windows")]
{
// Open the folder in Explorer, then use SetForegroundWindow + ShowWindow(SW_MAXIMIZE=3)
// to ensure the window surfaces on top of all other windows. The path is passed via
// an environment variable to avoid any command-injection or escaping issues.
let ps_cmd = "Add-Type -TypeDefinition \
'using System;using System.Runtime.InteropServices;\
public class WF{\
[DllImport(\"user32.dll\")]public static extern bool SetForegroundWindow(IntPtr h);\
[DllImport(\"user32.dll\")]public static extern bool ShowWindow(IntPtr h,int c);\
}'; \
$p=$env:SLOC_OPEN_PATH; \
$sh=New-Object -ComObject Shell.Application; \
$sh.Open($p); \
Start-Sleep -Milliseconds 600; \
foreach($w in $sh.Windows()){ \
try{ \
if([System.IO.Path]::GetFullPath($w.Document.Folder.Self.Path) -eq \
[System.IO.Path]::GetFullPath($p)){ \
[WF]::ShowWindow($w.HWND,3); \
[WF]::SetForegroundWindow($w.HWND); \
break \
} \
}catch{} \
}";
let _ = std::process::Command::new("powershell")
.args(["-NoProfile", "-WindowStyle", "Hidden", "-Command", ps_cmd])
.env("SLOC_OPEN_PATH", target.to_string_lossy().as_ref())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
}
#[cfg(target_os = "macos")]
let _ = std::process::Command::new("open")
.arg(&target)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
#[cfg(target_os = "linux")]
let _ = std::process::Command::new("xdg-open")
.arg(&target)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
(StatusCode::OK, "ok").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 state.server_mode {
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 canonical = fs::canonicalize(&resolved).unwrap_or_else(|_| resolved.clone());
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>,
}
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(serde_json::json!({ "found": found, "tool": tool, "hint": 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,
csp_nonce: csp_nonce.to_owned(),
};
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,
csp_nonce: csp_nonce.to_owned(),
};
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,
}
}
/// 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(),
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;
}
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())
};
}
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(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.
fn spawn_pdf_background(pending_pdf: PendingPdf) {
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;
match result {
Ok(Err(err)) => eprintln!("[oxide-sloc][pdf] background PDF failed: {err}"),
Err(err) => eprintln!("[oxide-sloc][pdf] background PDF task panicked: {err}"),
Ok(Ok(())) => {}
}
});
}
}
/// 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()
}
/// Build one `SubmoduleRow`, optionally generating and persisting a sub-report HTML file.
fn build_submodule_row(
s: &sloc_core::SubmoduleSummary,
run: &AnalysisRun,
run_id: &str,
run_dir: &Path,
generate_html: bool,
) -> SubmoduleRow {
let safe = sanitize_project_label(&s.name);
let artifact_key = format!("sub_{safe}");
let html_url = if run.effective_configuration.discovery.submodule_breakdown && generate_html {
let parent_path = run
.input_roots
.first()
.map_or("", std::string::String::as_str);
let sub_run = build_sub_run(run, s, parent_path);
render_sub_report_html(&sub_run).ok().and_then(|sub_html| {
let path = run_dir.join(format!("{artifact_key}.html"));
if fs::write(&path, sub_html.as_bytes()).is_ok() {
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::too_many_lines)]
#[allow(clippy::similar_names)]
async fn analyze_handler(
// NOSONAR(rust:S3776)
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: "Server is busy — too many concurrent analyses. Please try again in a moment."
.to_string(),
last_report_url: None,
last_report_label: None,
csp_nonce: csp_nonce.clone(),
};
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 {
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));
// Clone everything the background task needs before moving into the spawn.
let project_path_bg = form.path.clone();
let output_dir_bg = form.output_dir.clone();
let git_repo_bg = form.git_repo.clone().filter(|s| !s.is_empty());
let git_ref_bg = form.git_ref.clone().filter(|s| !s.is_empty());
let generate_html_bg = form.generate_html.is_some();
let generate_pdf_bg = form.generate_pdf.is_some();
let clones_dir = state.git_clones_dir.clone();
let wait_id_bg = wait_id.clone();
let state_bg = state.clone();
let cancel_bg = Arc::clone(&cancel_token);
{
let mut runs = state.async_runs.lock().await;
runs.insert(
wait_id.clone(),
AsyncRunState::Running {
started_at: std::time::Instant::now(),
cancel_token,
},
);
}
tokio::spawn(async move {
// Hold the permit for the lifetime of the background task.
let _permit = sem_permit;
// Clone before moving into spawn_blocking so we can use them again afterwards.
let git_repo_sb = git_repo_bg.clone();
let git_ref_sb = git_ref_bg.clone();
let cancel_sb = Arc::clone(&cancel_bg);
let analysis_result =
tokio::task::spawn_blocking(move || -> Result<(sloc_core::AnalysisRun, String)> {
if let (Some(repo), Some(refname)) = (&git_repo_sb, &git_ref_sb) {
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_sb));
let _ = sloc_git::destroy_worktree(&dest, &wt);
let mut run = run?;
if run.git_branch.is_none() {
run.git_branch = Some(refname.clone());
}
let html = render_html(&run)?;
return Ok((run, html));
}
let run = analyze(&config, "serve", Some(&cancel_sb))?;
let html = render_html(&run)?;
Ok((run, html))
})
.await
.map_err(|err| anyhow::anyhow!(err.to_string()))
.and_then(|result| result);
// If cancelled while running, discard results and mark as cancelled.
if cancel_bg.load(std::sync::atomic::Ordering::Relaxed) {
let mut runs = state_bg.async_runs.lock().await;
// Only overwrite if still Running (don't clobber a Complete that snuck in).
if matches!(
runs.get(&wait_id_bg),
Some(AsyncRunState::Running { .. } | AsyncRunState::Cancelled)
) {
runs.insert(wait_id_bg.clone(), AsyncRunState::Cancelled);
}
drop(runs);
return;
}
let (run, report_html) = match analysis_result {
Ok(v) => v,
Err(err) => {
// Distinguish user-cancelled from real failure.
let message = if err.to_string().contains("analysis cancelled") {
let mut runs = state_bg.async_runs.lock().await;
runs.insert(wait_id_bg.clone(), AsyncRunState::Cancelled);
drop(runs);
return;
} else {
"Analysis failed. Check that the path exists and is readable.".to_string()
};
eprintln!("[oxide-sloc][analyze] analysis failed: {err:#}");
let mut runs = state_bg.async_runs.lock().await;
runs.insert(wait_id_bg.clone(), AsyncRunState::Failed { message });
drop(runs);
return;
}
};
let run_id = run.tool.run_id.clone();
tracing::info!(event = "scan_complete", run_id = %run_id,
path = %project_path_bg, files = run.summary_totals.files_analyzed,
"Analysis finished");
let prev_entry: Option<RegistryEntry> = {
let reg = state_bg.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 = state_bg.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()
};
let output_root = resolve_output_root(output_dir_bg.as_deref());
let project_label = if let (Some(repo), Some(refname)) = (
git_repo_bg.as_deref().filter(|s| !s.is_empty()),
git_ref_bg.as_deref().filter(|s| !s.is_empty()),
) {
let repo_name = repo
.trim_end_matches('/')
.trim_end_matches(".git")
.rsplit('/')
.next()
.unwrap_or("repo");
sanitize_project_label(&format!("{repo_name}_{refname}"))
} else {
sanitize_project_label(&project_path_bg)
};
let run_dir = output_root.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.clone()
} else {
format!("{project_label}_{commit}")
}
};
let result_context = RunResultContext {
prev_entry: prev_entry.clone(),
prev_scan_count,
project_path: project_path_bg.clone(),
};
let artifact_result = persist_run_artifacts(
&run,
&report_html,
&run_dir,
true,
generate_html_bg,
generate_pdf_bg,
&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 = state_bg.async_runs.lock().await;
runs.insert(
wait_id_bg.clone(),
AsyncRunState::Failed {
message: "Failed to save report artifacts. Check available disk space."
.to_string(),
},
);
drop(runs);
return;
}
};
{
let mut map = state_bg.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 = state_bg.registry.lock().await;
reg.add_entry(entry);
let _ = reg.save(&state_bg.registry_path);
}
if let Some(ref cfg_path) = artifacts.scan_config_path {
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_bg.clone(),
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_bg.clone().unwrap_or_default(),
report_title: run.effective_configuration.reporting.report_title.clone(),
generate_html: generate_html_bg,
generate_pdf: generate_pdf_bg,
};
if let Ok(json) = serde_json::to_string_pretty(&scan_cfg) {
let _ = std::fs::write(cfg_path, json);
}
}
spawn_pdf_background(pending_pdf);
// Mark complete — client is now polling and will be redirected to /runs/result/{run_id}.
let mut runs = state_bg.async_runs.lock().await;
runs.insert(
wait_id_bg.clone(),
AsyncRunState::Complete {
run_id: run_id.clone(),
},
);
drop(runs);
// Submodule sub-reports are rendered synchronously above inside background task.
let _ = scan_delta;
});
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
}
// ── Async scan status + result handlers ──────────────────────────────────────
#[derive(Serialize)]
#[serde(tag = "state", rename_all = "snake_case")]
enum AsyncRunStatusResponse {
Running { elapsed_secs: 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 StatusCode::BAD_REQUEST.into_response();
}
let run_state = {
let runs = state.async_runs.lock().await;
runs.get(&wait_id).cloned()
};
match run_state {
None => StatusCode::NOT_FOUND.into_response(),
Some(AsyncRunState::Running { started_at, .. }) => {
// 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();
}
Json(AsyncRunStatusResponse::Running {
elapsed_secs: started_at.elapsed().as_secs(),
})
.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 StatusCode::BAD_REQUEST.into_response();
}
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(),
_ => StatusCode::NOT_FOUND.into_response(),
};
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()),
csp_nonce: csp_nonce.clone(),
}
.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()),
csp_nonce: csp_nonce.clone(),
}
.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)
}
#[allow(clippy::too_many_lines)]
#[allow(clippy::similar_names)] // abbreviated names (fa=files_analyzed, cl=code_lines, etc.) are intentional
fn render_result_page(
// NOSONAR(rust:S3776)
run: &AnalysisRun,
artifacts: &RunArtifacts,
run_id: &str,
csp_nonce: &str,
confluence_configured: 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_author = run.git_commit_author.clone();
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(),
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)),
pdf_path: artifacts.pdf_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_commit,
git_author,
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, artifacts.html_path.is_some()))
.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 entries: Vec<String> = run
.totals_by_language
.iter()
.take(12)
.map(|l| {
let name = l
.language
.display_name()
.replace('\\', "\\\\")
.replace('"', "\\\"");
format!(
r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
name,
l.code_lines,
l.comment_lines,
l.blank_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)
.map(|l| {
let name = l
.language
.display_name()
.replace('\\', "\\\\")
.replace('"', "\\\"");
format!(
r#"{{"lang":"{}","functions":{},"classes":{},"variables":{},"imports":{}}}"#,
name, l.functions, l.classes, l.variables, l.imports,
)
})
.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),
csp_nonce: csp_nonce.to_owned(),
confluence_configured,
report_header_footer: run
.effective_configuration
.reporting
.report_header_footer
.clone(),
};
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")
}
}
/// 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(serde_json::json!({"ready": ready})).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
/// 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) -> 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 {
(
[
(header::CONTENT_TYPE, "text/html; charset=utf-8"),
(
header::CONTENT_DISPOSITION,
"attachment; filename=report.html",
),
],
content,
)
.into_response()
} else {
Html(content).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\n\
Error: {err}\n\n\
If you moved or renamed the output folder, the stored path is now stale. \
Use 'Open HTML 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()),
csp_nonce: csp_nonce.to_owned(),
}
.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()),
csp_nonce: csp_nonce.to_owned(),
}
.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()),
csp_nonce: csp_nonce.to_owned(),
}
.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 {
let output_dir = entry
.html_path
.as_ref()
.or(entry.json_path.as_ref())
.or(entry.pdf_path.as_ref())
.and_then(|p| p.parent().map(PathBuf::from))
.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)
});
RunArtifacts {
output_dir: output_dir.clone(),
html_path: entry.html_path.clone(),
pdf_path,
json_path: entry.json_path.clone(),
scan_config_path: find_scan_config_in_dir(&output_dir),
report_title: entry.project_label.clone(),
result_context: RunResultContext::default(),
}
}
#[allow(clippy::too_many_lines)]
async fn artifact_handler(
// NOSONAR(rust:S3776)
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 = {
let registry = state.artifacts.lock().await;
registry.get(&run_id).cloned()
};
// Fall back to the persisted registry when the server was restarted and the
// in-memory artifact map no longer holds the entry.
let artifact_set = if let Some(a) = artifact_set {
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 short_id = &run_id[..run_id.len().min(8)];
let hint = if matches!(run_id.as_str(), "pdf" | "html" | "json" | "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()),
csp_nonce: csp_nonce.clone(),
}
.render()
.unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
return (StatusCode::NOT_FOUND, Html(error_html)).into_response();
}
};
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)
}
"pdf" => {
let Some(path) = artifact_set.pdf_path else {
let msg = "PDF report was not generated for this run, or was not recorded in \
the scan registry. 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()),
csp_nonce: csp_nonce.clone(),
}
.render()
.unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
return (StatusCode::NOT_FOUND, Html(html)).into_response();
};
// PDF path is recorded but the background task may still be writing it.
// Return a self-refreshing "please wait" page rather than an error.
if !path.exists() {
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{{max-width:1720px;margin:0 auto;padding:60px 24px;\
display:flex;align-items:center;justify-content:center;\
min-height:calc(100vh - 56px);}}\
.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>\
<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>\
<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 rendered from the HTML report.<br>\
This page refreshes automatically \u{2014} usually 15\u{2013}45 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>"
);
return Html(html).into_response();
}
serve_pdf_artifact(
&path,
&artifact_set.report_title,
&run_id,
wants_download,
&csp_nonce,
)
}
"json" => {
let Some(path) = artifact_set.json_path else {
let msg = "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();
let html = ErrorTemplate {
message: msg,
last_report_url: Some("/view-reports".to_string()),
last_report_label: Some("View Reports".to_string()),
csp_nonce: csp_nonce.clone(),
}
.render()
.unwrap_or_else(|_| "<pre>JSON not available.</pre>".to_string());
return (StatusCode::NOT_FOUND, Html(html)).into_response();
};
serve_json_artifact(&path, wants_download, &csp_nonce)
}
"scan-config" => {
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()
},
)
}
_ if artifact.starts_with("sub_") => {
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");
let path = artifact_set.output_dir.join(&filename);
if !path.exists() {
let html = ErrorTemplate {
message: 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."
),
last_report_url: Some("/view-reports".to_string()),
last_report_label: Some("View Reports".to_string()),
csp_nonce: csp_nonce.clone(),
}
.render()
.unwrap_or_else(|_| "<pre>Sub-report not found.</pre>".to_string());
return (StatusCode::NOT_FOUND, Html(html)).into_response();
}
serve_html_artifact(&path, wants_download, &csp_nonce)
}
_ => 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()
}
}
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,
};
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,
};
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,
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 files_analyzed = run
.per_file_records
.iter()
.filter(|r| r.language.is_some())
.count() as u64;
let code_lines: u64 = run
.per_file_records
.iter()
.map(|r| r.effective_counts.code_lines)
.sum();
let comment_lines: u64 = run
.per_file_records
.iter()
.map(|r| r.effective_counts.comment_lines)
.sum();
let blank_lines: u64 = run
.per_file_records
.iter()
.map(|r| r.effective_counts.blank_lines)
.sum();
run.summary_totals.files_analyzed = files_analyzed;
run.summary_totals.files_considered = files_analyzed;
run.summary_totals.code_lines = code_lines;
run.summary_totals.comment_lines = comment_lines;
run.summary_totals.blank_lines = blank_lines;
run.summary_totals.total_physical_lines = code_lines + comment_lines + blank_lines;
}
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::too_many_lines)]
async fn compare_handler(
// NOSONAR(rust:S3776)
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()),
csp_nonce: csp_nonce.clone(),
}
.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()),
csp_nonce: csp_nonce.clone(),
}
.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 read_json(base_json) {
Ok(r) => r,
Err(e) => {
if state.server_mode {
let html = ErrorTemplate {
message: "Could not load baseline scan data. The scan output folder may \
have been moved, renamed, or deleted. Re-running the analysis \
will create fresh comparison data."
.to_string(),
last_report_url: Some("/compare-scans".to_string()),
last_report_label: Some("Compare Scans".to_string()),
csp_nonce: csp_nonce.clone(),
}
.render()
.unwrap_or_else(|_| "<pre>Baseline load failed.</pre>".to_string());
return (StatusCode::NOT_FOUND, Html(html)).into_response();
}
let msg = format!(
"Could not load baseline scan data.\n\nExpected path: {}\n\nError: {e}",
base_json.display()
);
let folder_hint = base_json
.parent()
.map(|p| p.display().to_string())
.unwrap_or_default();
return missing_scan_relocate_response(
&msg,
&baseline_entry.run_id,
&folder_hint,
&compare_url,
false,
&csp_nonce,
);
}
};
let current_run = match read_json(curr_json) {
Ok(r) => r,
Err(e) => {
if state.server_mode {
let html = ErrorTemplate {
message: "Could not load current scan data. The scan output folder may \
have been moved, renamed, or deleted. Re-running the analysis \
will create fresh comparison data."
.to_string(),
last_report_url: Some("/compare-scans".to_string()),
last_report_label: Some("Compare Scans".to_string()),
csp_nonce: csp_nonce.clone(),
}
.render()
.unwrap_or_else(|_| "<pre>Current load failed.</pre>".to_string());
return (StatusCode::NOT_FOUND, Html(html)).into_response();
}
let msg = format!(
"Could not load current scan data.\n\nExpected path: {}\n\nError: {e}",
curr_json.display()
);
let folder_hint = curr_json
.parent()
.map(|p| p.display().to_string())
.unwrap_or_default();
return missing_scan_relocate_response(
&msg,
¤t_entry.run_id,
&folder_hint,
&compare_url,
false,
&csp_nonce,
);
}
};
let active_submodule = query.sub.clone();
let super_scope_active = query.scope.as_deref() == Some("super");
// Build the union of submodule names present in either run so users can
// scope to a submodule even when it only exists in one of the two scans.
let submodule_options = {
let mut names = std::collections::BTreeSet::new();
for s in &baseline_run.submodule_summaries {
names.insert(s.name.clone());
}
for s in ¤t_run.submodule_summaries {
names.insert(s.name.clone());
}
names.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) = if let Some(ref sub_name) = active_submodule {
let mut b = baseline_run;
let mut c = current_run;
b.per_file_records
.retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
c.per_file_records
.retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
recompute_summary_from_records(&mut b);
recompute_summary_from_records(&mut c);
(b, c)
} else if super_scope_active {
let mut b = baseline_run;
let mut c = current_run;
b.per_file_records.retain(|f| f.submodule.is_none());
c.per_file_records.retain(|f| f.submodule.is_none());
recompute_summary_from_records(&mut b);
recompute_summary_from_records(&mut c);
(b, c)
} else {
(baseline_run, current_run)
};
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,
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);
// True when the selected scope had no files in the baseline — e.g. comparing a submodule
// that only exists in the current scan or using Super-repo only on an older scan.
let new_scope = comparison.summary.baseline_code == 0 && comparison.summary.current_code > 0;
// ratio/percentage display, precision loss acceptable
#[allow(clippy::cast_precision_loss)]
let churn_pct = if comparison.summary.baseline_code > 0 {
(lines_added + lines_removed) as f64 / comparison.summary.baseline_code as f64 * 100.0
} else {
0.0
};
#[allow(clippy::cast_precision_loss)]
let scope_flag = new_scope
|| (comparison.summary.baseline_code > 0
&& lines_added as f64 / comparison.summary.baseline_code as f64 > 0.20);
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(),
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_rate_str: if new_scope {
"New".to_string()
} else if s.baseline_code > 0 {
format!("{churn_pct:.1}%")
} else {
"—".to_string()
},
churn_rate_class: if new_scope || churn_pct > 20.0 {
"high".into()
} else if churn_pct > 5.0 {
"med".into()
} else {
"low".into()
},
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,
};
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 ApiMetricsResponse {
run_id: String,
timestamp: String,
project: String,
summary: ApiSummaryPayload,
languages: Vec<ApiLanguageRow>,
}
#[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(
|| {
(
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "no scans recorded yet"})),
)
.into_response()
},
|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(
|| {
(
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "run not found"})),
)
.into_response()
},
|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;
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,
})
.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>,
}
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('\\', "/");
let entries: Vec<_> = {
let reg = state.registry.lock().await;
reg.entries
.iter()
.filter(|e| e.input_roots.iter().any(|r| r == &root_str))
.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>,
}
#[allow(clippy::too_many_lines)] // history aggregation with per-run metric computation and JSON building
async fn api_metrics_history_handler(
// NOSONAR(rust:S3776)
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: 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()));
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(MetricsSubmoduleLink {
name: display,
url: format!("/runs/{stem}/{}", e.run_id),
});
}
}
}
}
links.sort_by(|a, b| a.name.cmp(&b.name));
links
};
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,
};
if let Some(ref filter) = submodule_filter {
// Read the full JSON artifact to get per-submodule metrics.
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
})?;
// Point the report link to the submodule sub-report if it was generated.
let safe = sanitize_project_label(&sub.name);
let artifact_key = format!("sub_{safe}");
let sub_html_url = if let Some(run_dir) = std::path::Path::new(json_path).parent() {
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()
}
} else {
base.html_url.clone()
};
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,
html_url: sub_html_url,
has_pdf: false,
submodule_links: vec![],
..base
})
} 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) = std::fs::read_to_string(path) 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>,
}
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,
true,
true,
false,
&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(serde_json::json!({
"run_id": run_id,
"view_url": format!("/view-reports?run_id={run_id}"),
})),
)
.into_response()
}
Ok(Err(e)) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("{e:#}")})),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("{e}")})),
)
.into_response(),
}
}
// ── 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(
// NOSONAR(rust:S3776)
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).
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
})
};
let watched_dirs_html = 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);}}
.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{{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}}
.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.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;}}
.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:10px 16px;flex-wrap:wrap;margin-bottom:16px;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;}}
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="/webhook-setup"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
</div>
</div>
<div class="server-status-wrap">
<div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
<div class="server-status-tip">OxideSLOC is running as a local server in your terminal.<br>Close the terminal window to stop the server.</div>
</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="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…</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.json(); }})
.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 Math.round(v/1e3)+'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';}}
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…</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)+'–'+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('‹',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('›',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='↕';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='↕';t.classList.remove('sort-asc','sort-desc');}});
th.classList.add('sort-'+shSortOrder);
var si=th.querySelector('.sort-icon');if(si)si.textContent=shSortOrder==='asc'?'↑':'↓';
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 project…">'+
'<select class="filter-select" id="sh-branch-filter">'+branchOpts+'</select>'+
'<button type="button" class="btn" id="sh-reset-btn">↻ 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);
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">
oxide-sloc v{version} — local code analysis - metrics, history and reports ·
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>"##,
);
Html(html).into_response()
}
#[allow(clippy::cast_precision_loss)] // ratio/percentage display, precision loss acceptable
#[allow(clippy::too_many_lines)] // JSON data builder for test-metrics scope; splitting would scatter related fields
fn build_test_scope_entry(run: &AnalysisRun) -> serde_json::Value {
// NOSONAR(rust:S3776)
use std::collections::HashMap;
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 has_file_cov = run.per_file_records.iter().any(|f| f.coverage.is_some());
let cov_arr: Vec<serde_json::Value> = if has_file_cov {
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);
}
}
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()
} else {
vec![]
};
let (mut high, mut mid, mut low) = (0u64, 0u64, 0u64);
for rec in &run.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;
}
}
}
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 mut file_cov_arr: Vec<serde_json::Value> = run
.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();
file_cov_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)
});
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,
})
}
// 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(
// NOSONAR(rust:S3776)
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 reg = state.registry.lock().await;
let json_str: Option<String> = reg
.entries
.first()
.and_then(|e| e.json_path.as_ref())
.and_then(|p| std::fs::read_to_string(p).ok());
drop(reg);
json_str
.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
};
// Build per-language chart JSON (kept for has_coverage derivation via cov_json).
let _lang_tests_json: String = latest_run.as_ref().map_or_else(
|| "[]".to_string(),
|r| {
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| {
let name = l.language.display_name().replace('"', "\\\"");
let density = if l.code_lines > 0 {
// ratio for density display, precision loss acceptable
#[allow(clippy::cast_precision_loss)]
{ 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,
)
})
.collect();
format!("[{}]", parts.join(","))
},
);
// Build coverage chart JSON (per-language avg line coverage %).
let cov_json: String = match &latest_run {
Some(r) if r.per_file_records.iter().any(|f| f.coverage.is_some()) => {
use std::collections::HashMap;
let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
for rec in &r.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);
}
}
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(","))
}
_ => "[]".to_string(),
};
// Coverage tier distribution (pre-computed into SCOPE_DATA; unused as format arg).
let _cov_tier_json: String = match &latest_run {
Some(r) if r.per_file_records.iter().any(|f| f.coverage.is_some()) => {
let mut high = 0u64; // >= 80%
let mut mid = 0u64; // 50-79%
let mut low = 0u64; // < 50%
for rec in &r.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;
}
}
}
format!(r#"{{"high":{high},"mid":{mid},"low":{low}}}"#)
}
_ => r#"{"high":0,"mid":0,"low":0}"#.to_string(),
};
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");
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
})
};
let watched_dirs_html = 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: String = {
let mut scope_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
scope_map.insert(
"__all__".to_string(),
latest_run.as_ref().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":"—",
"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 run_for_root: Option<AnalysisRun> = {
let reg = state.registry.lock().await;
let json_str = reg
.entries
.iter()
.find(|e| e.input_roots.iter().any(|r| r == root))
.and_then(|e| e.json_path.as_ref())
.and_then(|p| std::fs::read_to_string(p).ok());
drop(reg);
json_str
.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
};
if let Some(ref run) = run_for_root {
let mut root_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();
root_entry["submodules"] = serde_json::Value::Object(subs);
}
scope_map.insert(root.clone(), root_entry);
}
}
serde_json::to_string(&scope_map).unwrap_or_else(|_| "{}".to_string())
};
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);}}
.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{{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}}
.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;white-space:nowrap;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;}}
.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:10px 16px;margin-bottom:16px;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:10px 16px;flex-wrap:wrap;margin-bottom:16px;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;}}
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);}}
</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="/webhook-setup"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
</div>
</div>
<div class="server-status-wrap">
<div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
<div class="server-status-tip">OxideSLOC is running as a local server in your terminal.<br>Close the terminal window to stop the server.</div>
</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">
<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="chart-row">
<div class="chart-box">
<div class="chart-box-title">Test Definitions by Language</div>
<div class="chart-canvas-wrap"><canvas id="canvas-tests"></canvas></div>
</div>
<div class="chart-box">
<div class="chart-box-title">Test Density (per 1 000 code lines)</div>
<div class="chart-canvas-wrap"><canvas id="canvas-density"></canvas></div>
</div>
</div>
<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 class="panel">
<div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">Test Count Trend</div>
<p class="muted" style="margin-bottom:14px;">Test definition count across all saved scans for the selected scope.</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>
<footer class="site-footer">
oxide-sloc v{version} — local code analysis - metrics, history and reports ·
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.json(); }})
.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 ALL_CHARTS = [];
function fmt(n){{var v=Number(n),a=Math.abs(v);if(a>=1e6)return(v/1e6).toFixed(1).replace(/\.0$/,'')+'M';if(a>=1e4)return Math.round(v/1e3)+'K';return v.toLocaleString();}}
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 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 renderTestCharts(D) {{
testsChart = destroyChart(testsChart);
densityChart = destroyChart(densityChart);
if (!D || !D.length) return;
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',
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}} }} }}
}}
}}
}});
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',
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}} }} }}
}}
}}
}});
ALL_CHARTS.push(densityChart);
}}
}}
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);
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();
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,
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); }} }} }}
}}
}}
}});
ALL_CHARTS.push(trendChart);
}}
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);
}}).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>
</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>"#
)
}
#[allow(clippy::too_many_arguments)]
fn persist_run_artifacts(
run: &sloc_core::AnalysisRun,
report_html: &str,
run_dir: &Path,
generate_json: bool,
generate_html: bool,
generate_pdf: bool,
report_title: &str,
file_stem: &str,
result_context: RunResultContext,
) -> Result<(RunArtifacts, PendingPdf)> {
fs::create_dir_all(run_dir)
.with_context(|| format!("failed to create output directory {}", run_dir.display()))?;
let mut html_path = None;
let mut pdf_path = None;
let mut json_path = None;
let mut pending_pdf: Option<(PathBuf, PathBuf, bool)> = None;
if generate_html {
let path = run_dir.join(format!("report_{file_stem}.html"));
fs::write(&path, report_html)
.with_context(|| format!("failed to write HTML report to {}", path.display()))?;
html_path = Some(path);
}
if generate_json {
let path = run_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 report to {}", path.display()))?;
json_path = Some(path);
}
if generate_pdf {
let source_html_path = if let Some(existing) = html_path.as_ref() {
existing.clone()
} else {
let temp_html = run_dir.join("_report_rendered.html");
fs::write(&temp_html, report_html).with_context(|| {
format!(
"failed to write temporary HTML report to {}",
temp_html.display()
)
})?;
temp_html
};
let pdf_dest = run_dir.join(format!("report_{file_stem}.pdf"));
let cleanup_src = !generate_html;
pdf_path = Some(pdf_dest.clone());
pending_pdf = Some((source_html_path, pdf_dest, cleanup_src));
}
let scan_config_path = Some(run_dir.join(format!("scan-config_{file_stem}.json")));
Ok((
RunArtifacts {
output_dir: run_dir.to_path_buf(),
html_path,
pdf_path,
json_path,
scan_config_path,
report_title: report_title.to_string(),
result_context,
},
pending_pdf,
))
}
/// Find a scan-config JSON file in `dir`, checking both the legacy fixed name and
/// the current `scan-config_<stem>.json` pattern for backwards compatibility.
fn find_scan_config_in_dir(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 ────────────────────────────────────────────────────
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(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 (
StatusCode::UNPROCESSABLE_ENTITY,
Json(serde_json::json!({ "error": e.to_string() })),
)
.into_response();
}
Json(serde_json::json!({ "ok": true, "config": config })).into_response()
}
Err(e) => (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": format!("TOML parse error: {e}") })),
)
.into_response(),
}
}
// ── Scan profiles API ─────────────────────────────────────────────────────────
async fn api_list_scan_profiles(State(state): State<AppState>) -> impl IntoResponse {
let store = state.scan_profiles.lock().await;
Json(serde_json::json!({ "profiles": store.profiles }))
}
#[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 (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "name must not be empty" })),
)
.into_response();
}
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(serde_json::json!({ "ok": true, "id": 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 (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "profile not found" })),
)
.into_response();
}
if let Err(e) = store.save(&state.scan_profiles_path) {
tracing::warn!("failed to persist scan profiles: {e}");
}
drop(store);
Json(serde_json::json!({ "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)?;
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,
true,
true,
false,
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()
}
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);
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: 0,
functions: 0,
classes: 0,
variables: 0,
imports: 0,
test_count: 0,
test_assertion_count: 0,
test_suite_count: 0,
coverage_lines_found: 0,
coverage_lines_hit: 0,
coverage_functions_found: 0,
coverage_functions_hit: 0,
coverage_branches_found: 0,
coverage_branches_hit: 0,
},
totals_by_language: sub.language_summaries.clone(),
per_file_records: sub_files,
skipped_file_records: vec![],
warnings: vec![],
submodule_summaries: vec![],
git_commit_short: parent.git_commit_short.clone(),
git_commit_long: parent.git_commit_long.clone(),
git_branch: parent.git_branch.clone(),
git_commit_author: parent.git_commit_author.clone(),
git_commit_date: parent.git_commit_date.clone(),
git_tags: parent.git_tags.clone(),
git_nearest_tag: parent.git_nearest_tag.clone(),
}
}
pub(crate) fn sanitize_project_label(raw: &str) -> String {
let candidate = Path::new(raw)
.file_name()
.and_then(|name| name.to_str())
.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
}
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")
}
}
#[allow(clippy::too_many_lines)]
fn build_preview_html(
// NOSONAR(rust:S3776)
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() {
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>{}</strong> git submodule{} detected</div>"#,
count,
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>");
}
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">"#);
if languages.is_empty() {
out.push_str(
r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
);
} else {
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();
}
}
}
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(
// NOSONAR(rust:S3776)
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 40px; flex: 1; width: 100%; }
.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-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, .artifact-card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); transition: border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease, transform 0.18s ease; }
.summary-card:hover, .workspace-card:hover, .explainer-card:hover, .artifact-card:hover, .review-card:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); transform: translateY(-2px); }
.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:start; min-height: calc(100vh - 57px); }
.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; }
.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; }
.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; }
.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; }
.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 .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; }
.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-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-top: 16px; margin-bottom: 48px !important; }
.artifact-card { position:relative; padding: 16px; cursor:pointer; }
.artifact-card.selected { border-color: var(--accent); box-shadow: 0 0 0 1px rgba(37,99,235,0.18), var(--shadow-strong); }
.artifact-card .marker { position:absolute; top: 12px; right: 12px; width: 22px; height: 22px; border-radius: 999px; border:2px solid var(--line-strong); display:flex; align-items:center; justify-content:center; font-size: 12px; color: transparent; }
.artifact-card.selected .marker { background: var(--accent); border-color: var(--accent); color: #fff; }
.artifact-card.artifact-locked { background: rgba(0,0,0,0.055); cursor:not-allowed; }
.artifact-card.artifact-locked:hover { transform: none !important; box-shadow: 0 0 0 1px rgba(37,99,235,0.18), var(--shadow-strong) !important; }
body.dark-theme .artifact-card.artifact-locked { background: rgba(255,255,255,0.055); }
.artifact-card.artifact-locked .marker { background: #a0aab4 !important; border-color: #a0aab4 !important; color: #fff !important; }
body.dark-theme .artifact-card.artifact-locked .marker { background: #6b7280 !important; border-color: #6b7280 !important; }
.artifact-icon { width: 42px; height: 42px; border-radius: 12px; background: var(--surface-2); border:1px solid var(--line); display:flex; align-items:center; justify-content:center; font-size: 22px; font-weight: 900; }
.artifact-card h4 { margin: 12px 0 6px; font-size: 16px; }
.artifact-card p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.6; }
.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: 200px; }
.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; }
.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(730px, calc(100vw - 40px)); border-radius: 18px; border: 1px solid var(--line); background: var(--surface); box-shadow: 0 20px 48px rgba(0,0,0,0.22); padding: 36px 42px; }
.progress-bar { width:100%; height:6px; 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:8px;background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.28);border-radius:999px;padding:5px 14px 5px 10px;font-size:12px;font-weight:700;color:var(--accent-2);margin-bottom:16px; }
.lc-dot { width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:lcPulse 1.4s ease-in-out infinite;flex:0 0 auto; }
@keyframes lcPulse { 0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);} }
.lc-title { font-size:1.25rem;font-weight:800;margin:0 0 6px; }
.lc-sub { color:var(--muted);font-size:0.88rem;margin:0 0 16px; }
.lc-path { background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:8px 14px;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px;color:var(--muted);word-break:break-all;margin-bottom:16px; }
.lc-metrics { display:flex;gap:16px;margin-bottom:20px; }
.lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:14px 28px;flex:0 0 auto;min-width:140px; }
.lc-metric-label { font-size:12px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px; }
.lc-metric-value { font-size:1.2rem;font-weight:700;color:var(--text); }
.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); }
</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-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="/webhook-setup"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
</div>
</div>
<div class="server-status-wrap">
<div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
<div class="server-status-tip">OxideSLOC is running as a local server in your terminal.<br>Close the terminal window to stop the server.</div>
</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"></span>Analysis running</div>
<h2 class="lc-title" id="lc-title">Analyzing your project…</h2>
<p class="lc-sub">Results are saved automatically — you can leave this page.</p>
<div class="lc-path" id="lc-path"></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>
<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">41 languages</span>
</span>
<div class="ws-lang-tooltip">
<div class="ws-lang-tooltip-hdr">41 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>
</div>
</div>
</div>
<div class="ws-divider"></div>
<div class="ws-stat" data-wb-tip="Localhost mode — all scans run on this machine against local file system paths."><span class="ws-label">Mode</span><span class="ws-value">Localhost</span></div>
<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>
<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>
</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">Browse</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() %}
<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">
<label for="include_globs">Include globs</label>
<textarea id="include_globs" name="include_globs" placeholder="examples: src/**/*.py scripts/*.sh"></textarea>
<div class="hint">Use line-separated or comma-separated patterns when you want to narrow the scan to only certain folders or file types. If you leave this empty, everything under the project path is eligible first, and then exclude rules trim it down.</div>
</div>
<div class="field">
<label for="exclude_globs">Exclude globs</label>
<textarea id="exclude_globs" name="exclude_globs" 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><code>src/**/*.rs</code> only Rust sources in src, <code>scripts/*</code> top-level scripts folder, <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="ieee-note">Counting methodology follows IEEE Std 1045-1992 physical SLOC.</div>
<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="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="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>
<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>
</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="section">
<div class="section-kicker">Artifacts</div>
<div class="artifact-grid" style="margin-bottom:24px;">
<div class="artifact-card selected" data-artifact="html" data-review-label="HTML report">
<div class="marker">✓</div>
<div class="artifact-icon">H</div>
<h4>HTML report</h4>
<p>Interactive browser-friendly report for reading totals, drilling into language breakdowns, and previewing saved output in the UI.</p>
<div class="artifact-tags">
<span class="soft-chip">Best for visual review</span>
<span class="soft-chip">Embeddable preview</span>
</div>
<input type="checkbox" name="generate_html" checked class="hidden artifact-checkbox" />
</div>
<div class="artifact-card selected" data-artifact="pdf" data-review-label="PDF export">
<div class="marker">✓</div>
<div class="artifact-icon">P</div>
<h4>PDF export</h4>
<p>Printable snapshot for sharing, archiving, or attaching to reviews when a fixed-format artifact is more useful than live HTML.</p>
<div class="artifact-tags">
<span class="soft-chip">Portable snapshot</span>
<span class="soft-chip">Good for handoff</span>
</div>
<input type="checkbox" name="generate_pdf" checked class="hidden artifact-checkbox" />
</div>
<div class="artifact-card selected artifact-locked" data-artifact="json" data-review-label="JSON result (always on)" style="opacity:0.85;pointer-events:none;">
<div style="position:absolute;inset:0;border-radius:inherit;background:radial-gradient(ellipse at center, transparent 30%, rgba(0,0,0,0.13) 100%);pointer-events:none;z-index:2;"></div>
<div class="marker">✓</div>
<div class="artifact-icon" style="color:var(--muted);">J</div>
<h4>JSON result <span style="font-size:11px;font-weight:700;color:var(--muted);">always on</span></h4>
<p>Machine-readable output always saved — required for run comparison, diff, and history features.</p>
<div class="artifact-tags">
<span class="soft-chip">Required for compare</span>
<span class="soft-chip">Auto-enabled</span>
</div>
<input type="checkbox" name="generate_json" checked class="hidden artifact-checkbox" />
</div>
</div>
<div style="height:48px;flex-shrink:0;display:block;"></div>
<div class="hint">HTML and PDF cards are selectable. Presets above can also toggle them for common workflows. JSON output is always generated.</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></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 themeToggle = document.getElementById("theme-toggle");
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");
// 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 artifactCards = Array.prototype.slice.call(document.querySelectorAll(".artifact-card"));
var reportTitleTouched = false;
var currentStep = 1;
var previewTimer = null;
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 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);
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");
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";
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;
function lcSetPhase(txt) { var el = document.getElementById("lc-phase"); if (el) el.textContent = txt; }
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(s < 10 ? "Starting" : s < 30 ? "Scanning files" : "Analyzing");
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: "Review bundle enables HTML and PDF so you can inspect the result in-browser and still save a portable snapshot for sharing or archiving.",
chips: ["HTML", "PDF"],
example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = false'
},
full: {
description: "Full bundle enables HTML, PDF, and JSON. It is the best choice when you want both human-readable outputs and a machine-friendly artifact for later processing.",
chips: ["HTML", "PDF", "JSON"],
example: 'generate_html = true\ngenerate_pdf = true\ngenerate_json = true'
},
html_only: {
description: "HTML only keeps the run lightweight and browser-first. It is ideal for quick local inspection when you do not need a fixed snapshot or automation output.",
chips: ["HTML only", "Fast local review"],
example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = false'
},
machine: {
description: "Machine bundle emphasizes structured output for downstream tooling. It is useful when the run is feeding scripts, dashboards, or other local automation.",
chips: ["HTML", "JSON"],
example: 'generate_html = true\ngenerate_pdf = false\ngenerate_json = true'
}
};
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];
var artifactInfo = artifactPresetInfo[artifactPreset.value];
document.getElementById("scan-preset-description").textContent = scanInfo.description;
document.getElementById("scan-preset-example").textContent = scanInfo.example;
document.getElementById("scan-preset-note").textContent = scanInfo.note;
document.getElementById("artifact-preset-description").textContent = artifactInfo.description;
document.getElementById("artifact-preset-example").textContent = artifactInfo.example;
renderPresetChips("scan-preset-summary", scanInfo.chips);
renderPresetChips("artifact-preset-summary", artifactInfo.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 applyArtifactPreset() {
var enabled = { html: false, pdf: false };
if (artifactPreset.value === "review") { enabled.html = true; enabled.pdf = true; }
if (artifactPreset.value === "full") { enabled.html = true; enabled.pdf = true; }
if (artifactPreset.value === "html_only") { enabled.html = true; }
if (artifactPreset.value === "machine") { enabled.html = true; }
artifactCards.forEach(function (card) {
var artifact = card.getAttribute("data-artifact");
if (artifact === "json") return;
var checked = !!enabled[artifact];
var checkbox = card.querySelector(".artifact-checkbox");
checkbox.checked = checked;
card.classList.toggle("selected", checked);
});
}
function toggleArtifactCard(card) {
var checkbox = card.querySelector(".artifact-checkbox");
checkbox.checked = !checkbox.checked;
card.classList.toggle("selected", checkbox.checked);
}
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>";
var selectedArtifacts = artifactCards.filter(function (card) { return card.classList.contains("selected"); }).map(function (card) { return card.getAttribute("data-review-label") || card.querySelector("h4").textContent; });
artifactSummary.innerHTML = ""
+ "<li>Artifact preset: " + escapeHtml(artifactPreset.options[artifactPreset.selectedIndex].text) + "</li>"
+ "<li>Selected artifacts: " + escapeHtml(selectedArtifacts.join(", ") || "none") + "</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) {
var selectedArtifactsCount = selectedArtifacts.length;
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>Artifact count selected: ' + escapeHtml(String(selectedArtifactsCount)) + '</li>'
+ '<li>Ready to run: ' + ((pathInput.value && selectedArtifactsCount > 0) ? '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 : "";
previewPanel.innerHTML = '<div class="preview-error">Refreshing preview...</div>';
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) {
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');
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) {
previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
});
}
function pickDirectory(targetInput, kind) {
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.json(); })
.then(function (data) {
if (data && data.selected_path) {
targetInput.value = data.selected_path;
if (targetInput === pathInput) {
updateReportTitleFromPath();
autoSetOutputDir(data.selected_path);
fetchProjectHistory(data.selected_path);
loadPreview();
suggestCoverageFile(data.selected_path);
}
updateReview();
} else if (targetInput === pathInput) {
// Cancelled — keep existing value and refresh preview with current path
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"); });
if (browseCoverage) {
browseCoverage.addEventListener("click", function () {
browseCoverage.disabled = true;
var currentVal = coverageInput ? coverageInput.value : "";
fetch("/pick-directory?kind=coverage¤t=" + encodeURIComponent(currentVal))
.then(function (r) { return r.json(); })
.then(function (d) {
if (d && d.selected_path && coverageInput) {
coverageInput.value = d.selected_path;
setCovStatus("idle");
}
})
.catch(function () {})
.finally(function () { browseCoverage.disabled = false; });
});
}
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">Using this file' + 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 this file</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 + ' detected — no coverage file found yet</div>';
html += '<div class="cov-scan-sub">Generate one with:</div>';
html += '<div class="cov-scan-actions"><code class="cov-scan-cmd">' + escapeHtml(opts.hint) + '</code></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 autoSetOutputDir(projectPath) {
if (!outputDirInput || outputDirInput.dataset.userEdited) return;
if (GIT_MODE && GIT_OUTPUT_DIR) {
outputDirInput.value = GIT_OUTPUT_DIR;
syncStripOutputRoot();
updateReview();
return;
}
if (!projectPath || !projectPath.trim()) return;
var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
outputDirInput.value = cleaned + "/sloc";
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?Math.round(v/1e3)+'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 : "";
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(); });
artifactCards.forEach(function (card) {
card.addEventListener("click", function () {
if (card.classList.contains("artifact-locked")) return;
toggleArtifactCard(card);
updateReview();
});
});
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));
});
}
Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
btn.addEventListener('click', function () {
var folder = btn.getAttribute('data-folder') || btn.dataset.folder || '';
if (!folder) return;
fetch('/open-path?path=' + encodeURIComponent(folder)).catch(function () {});
});
});
// Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
if (wsOutputLink) {
wsOutputLink.addEventListener('click', function () {
var folder = wsOutputLink.dataset.folder || '';
if (!folder) return;
fetch('/open-path?path=' + encodeURIComponent(folder)).catch(function () {});
});
}
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
loadPreview();
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; }
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-input', 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-input');
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';});
});
})();
(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();}
}());
</script>
<footer class="site-footer">
oxide-sloc v{{ version }} — local code analysis - metrics, history and reports ·
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,
}
// ── 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">
<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);}
.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:1400px;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{max-width:1400px;margin:0 auto;padding:18px 24px 12px;position:relative;z-index:1;}
.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:2.5em;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;}}
.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;}
</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="/webhook-setup"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
</div>
</div>
<div class="server-status-wrap">
{% if server_mode %}
<div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
<div class="server-status-tip">OxideSLOC is running in server mode — accessible on your LAN.<br>Use Ctrl+C in the terminal to stop.</div>
{% else %}
<div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
<div class="server-status-tip">OxideSLOC is running locally — only accessible from this machine.<br>Press Ctrl+C in the terminal to stop.</div>
{% endif %}
</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. They will be asked to authenticate.</p>
<div class="lan-auth-row">curl -H "Authorization: Bearer $SLOC_API_KEY" http://{{ ip }}:{{ port }}/healthz</div>
{% 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>.</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 29 more</div>
<div class="chip-slide">
<div class="info-chip-val">41</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">
oxide-sloc v{{ version }} — local code analysis - metrics, history and reports ·
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:'41',l:'Languages'},{v:'Rust · Go · Python',l:'and 38 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>
</body>
</html>
"##,
ext = "html"
)]
struct SplashTemplate {
csp_nonce: String,
server_mode: bool,
lan_ip: Option<String>,
port: u16,
version: &'static str,
}
// ── 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);}
.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:960px;margin:0 auto;padding:40px 24px 64px;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;}}
.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-online-pill{cursor:default;}
</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="/webhook-setup"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
</div>
</div>
<div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</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">
oxide-sloc v{{ version }} — local code analysis - metrics, history and reports ·
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');
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>
</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; }
.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 { max-width: 1720px; margin: 0 auto; padding: 18px 24px 40px; }
.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; align-items:center; gap: 14px; 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; }
.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:7px; padding:0 16px 0 12px; background:linear-gradient(135deg,rgba(26,143,71,0.12),rgba(26,143,71,0.06)); color:var(--success-text); border:1.5px solid rgba(26,143,71,0.35); box-shadow:0 0 0 4px rgba(26,143,71,0.07),0 2px 8px rgba(26,143,71,0.12); font-size:12px; letter-spacing:0.02em; }
.soft-chip.success svg { flex:0 0 auto; }
body.dark-theme .soft-chip.success { background:linear-gradient(135deg,rgba(143,226,168,0.12),rgba(143,226,168,0.05)); border-color:rgba(143,226,168,0.3); box-shadow:0 0 0 4px rgba(143,226,168,0.07),0 2px 8px rgba(0,0,0,0.2); }
.toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
.muted { color: var(--muted); }
.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(6,1fr); gap:10px; margin-top:18px; }
@media(max-width:1100px){.summary-strip{grid-template-columns:repeat(3,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:7px 12px; border-radius:8px; font-size:11px; white-space:nowrap; 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; }
/* 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; }
}
.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-chart-container .rchit{cursor:pointer;transition:opacity .17s,filter .17s;}
.r-chart-container .rchit:hover{opacity:.75;filter:brightness(1.14);}
.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:9999;box-shadow:0 4px 20px rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.1);max-width:240px;white-space:nowrap;}
.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-2);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;text-align:center;height:27px;line-height:27px;padding:0 16px;position:fixed;top:0;left:0;right:0;z-index:32;width:100%;}
.report-id-footer-banner{background:var(--nav);color:#fff;font-size:11px;font-weight:700;letter-spacing:0.05em;text-align:center;height:27px;line-height:27px;padding:0 16px;position:fixed;bottom:0;left:0;right:0;z-index:32;width:100%;}
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="/webhook-setup"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
</div>
</div>
<div class="server-status-wrap">
<div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
<div class="server-status-tip">OxideSLOC is running as a local server in your terminal.<br>Close the terminal window to stop the server.</div>
</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 class="soft-chip success"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg>Run finished successfully</div>
<h1 class="hero-title">{{ report_title }}</h1>
<p class="hero-subtitle">Your HTML, PDF, and JSON artifacts are now saved. Use the quick actions below to view, download, or copy the saved paths for sharing outside oxide-sloc.</p>
</div>
<div class="hero-quick-actions">
<button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
<button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
<button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
</div>
</div>
<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 physical lines 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">Executable source lines (IEEE 1045 SLOC)</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 classified as comments or 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</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">Source files successfully parsed and counted</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 and method definitions</div>
</div>
</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-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 %}
<a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;flex:0 0 auto;">Full diff →</a>
</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 %}{% 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_path %}
{% when Some with (_path) %}{% when None %}{% endmatch %}
<p class="action-empty-note" style="margin-top:6px;">Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
</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 %}
{% 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:15%"><col style="width:31%"><col style="width:9%"><col style="width:9%"><col style="width:9%"><col style="width:9%"><col style="width:9%"><col style="width:9%"></colgroup>
<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>
<div id="r-tt" aria-hidden="true"></div>
<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>
</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">
<p class="r-viz-card-title">Language Composition</p>
<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">
<p class="r-viz-card-title">Files vs Code Lines</p>
<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:12px;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>
</select>
</div>
<div class="r-chart-container" id="r-semantic-chart"></div>
</div>
{% endif %}
{% if has_submodule_data %}
<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;">Submodule Breakdown</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>
</div>
<div class="r-chart-container" id="r-submodule-chart"></div>
</div>
{% endif %}
</div>
</section>
</div>
</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;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(value).catch(function () {});
}
});
});
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;
fetch('/open-path?path=' + encodeURIComponent(folder)).catch(function () {});
});
});
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 Math.round(v/1e3)+'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():'';
});
})();
// ── 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);
});
})();
// ── 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 Math.round(v/1e3)+'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 — fixed 240×240 viewBox, legend to the right inside the SVG
var cx=100,cy=110,Ro=88,Ri=48;
var legX=204,DW=360,DH=220;
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+'%)')+' 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>';
var legRows=Math.min(D.length,8);
var legYStart=Math.round((DH-legRows*22)/2);
D.forEach(function(d,i){
if(i>=8)return;
var ly=legYStart+i*22;
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="11" fill="#43342d">'+esc(d.lang)+'</text>';
});
ds+='</svg>';
// Horizontal stacked-bar chart — fills container width
var maxT=Math.max.apply(null,D.map(function(d){return 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 cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
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')+' 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')+' 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')+' 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(d.code+d.comments+d.blanks)+'</text>';
});
var ly=SH-14;
bs+='<rect x="'+LW+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"/><text x="'+(LW+13)+'" y="'+(ly+9)+'" font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Code</text>';
bs+='<rect x="'+(LW+54)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"/><text x="'+(LW+67)+'" y="'+(ly+9)+'" font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Comments</text>';
bs+='<rect x="'+(LW+152)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"/><text x="'+(LW+165)+'" y="'+(ly+9)+'" font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Blanks</text>';
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>';
})();
// ── 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 Math.round(v/1e3)+'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 renderComposition(mode){
var el=document.getElementById('r-composition-chart');
if(!el||!LANG_D||!LANG_D.length)return;
var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
var LW=110,SH=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">';
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')+' 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')+' 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')+' 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="10" 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')+' 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')+' 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')+' x="'+px(x)+'" y="'+y+'" width="'+px(blW)+'" height="'+bH+'" fill="'+GY+'"/>';
s+='<text x="'+(LW+BW+4)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="10" fill="currentColor">'+fmt((d.code||0)+(d.comments||0)+(d.blanks||0))+'</text>';
});
}
var ly=SH-legendH+4;
s+='<rect x="'+LW+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"/><text x="'+(LW+13)+'" y="'+(ly+9)+'" font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Code</text>';
s+='<rect x="'+(LW+53)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"/><text x="'+(LW+66)+'" y="'+(ly+9)+'" font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Comments</text>';
s+='<rect x="'+(LW+152)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"/><text x="'+(LW+165)+'" y="'+(ly+9)+'" font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Blank</text>';
s+='</svg>';
el.innerHTML=s;
}
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(){
var el=document.getElementById('r-scatter-chart');
if(!el||!SCAT_D||!SCAT_D.length)return;
var H=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;
})();
// ── 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 renderSemantic(key){
var el=document.getElementById('r-semantic-chart');
if(!el||!SEM_D||!SEM_D.length)return;
var LW=112,SH=224;
var svgW=Math.max(320,el.offsetWidth||480);
var BW=Math.max(120,svgW-LW-80);
var topPad=4,botPad=14;
var n2=SEM_D.length||1;
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="10" fill="currentColor" opacity="0.8" style="pointer-events:none;">'+fmt(v)+'</text>';
});
s+='</svg>';
el.innerHTML=s;
}
var semSel=document.getElementById('r-semantic-metric');
if(semSel){renderSemantic('functions');semSel.addEventListener('change',function(){renderSemantic(semSel.value);});}
// ── Submodule: horizontal bar chart ────────────────────────────────────
function renderSubmodule(key,sort){
var el=document.getElementById('r-submodule-chart');
if(!el||!SUB_D||!SUB_D.length)return;
var data=SUB_D.slice();
if(sort==='desc')data.sort(function(a,b){return(b[key]||0)-(a[key]||0);});
else if(sort==='asc')data.sort(function(a,b){return(a[key]||0)-(b[key]||0);});
else data.sort(function(a,b){return(a.name||'').localeCompare(b.name||'');});
var LW=128,SH=224;
var svgW=Math.max(320,el.offsetWidth||480);
var BW=Math.max(120,svgW-LW-80);
var topPad3=4,botPad3=14;
var n3=data.length||1;
var rowTotal3=Math.floor((SH-topPad3-botPad3)/n3);
var bH=Math.min(22,Math.max(10,Math.floor(rowTotal3*0.65)));
var maxV=Math.max.apply(null,data.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">';
data.forEach(function(d,i){
var v=d[key]||0,bw=v/maxV*BW,y=topPad3+i*rowTotal3+Math.floor((rowTotal3-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.name||d.path||'?')+'</text>';
if(bw>0.5)s+='<rect'+tt(d.name||'?',fmt(v))+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+COLS[i%COLS.length]+'" rx="3"/>';
s+='<text x="'+(LW+px(bw)+6)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="10" fill="currentColor" opacity="0.8" style="pointer-events:none;">'+fmt(v)+'</text>';
});
s+='</svg>';
el.innerHTML=s;
}
var subSel=document.getElementById('r-sub-metric');
var sortSel=document.getElementById('r-sub-sort');
if(subSel){
renderSubmodule('code','desc');
subSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel?sortSel.value:'desc');});
if(sortSel)sortSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel.value);});
}
// 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');
(function(){
var scEl=document.getElementById('r-scatter-chart');
if(!scEl||!SCAT_D||!SCAT_D.length)return;
var H=224,PL=52,PB=36,PT=12,PR=14;
var W=Math.max(320,scEl.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>';scEl.innerHTML=s;
})();
if(semSel)renderSemantic(semSel.value||'functions');
if(subSel)renderSubmodule(subSel.value||'code',sortSel?sortSel.value:'desc');
},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">
oxide-sloc v{{ version }} — local code analysis - metrics, history and reports ·
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 %}
{% 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>,
pdf_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_commit: Option<String>,
git_author: Option<String>,
// 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,
/// Header/footer identification banner, mirrored from the HTML/PDF report.
report_header_footer: Option<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 | 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);}
.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{max-width:1720px;margin:0 auto;padding:32px 24px 80px;}
.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="/webhook-setup"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
</div>
</div>
<div class="server-status-wrap">
<div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
<div class="server-status-tip">OxideSLOC is running as a local server in your terminal.<br>Close the terminal window to stop the server.</div>
</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">This may take a few minutes for large repositories. You can leave this page — results are saved automatically.</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>
<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 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(s < 10 ? 'Starting' : s < 30 ? 'Scanning files' : 'Analyzing');
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);
})();
</script>
<footer class="site-footer">
oxide-sloc v{{ version }} — local code analysis - metrics, history and reports ·
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>
</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);}
.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:1720px;margin:0 auto;padding:28px 24px 40px;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 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);}
.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="/webhook-setup"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
</div>
</div>
<div class="server-status-wrap">
<div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
<div class="server-status-tip">OxideSLOC is running as a local server in your terminal.<br>Close the terminal window to stop the server.</div>
</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">{{ message }}</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>
</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");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>
</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>,
csp_nonce: String,
}
// ── 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);}
.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:860px;margin:0 auto;padding:28px 24px 40px;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;}
.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;}
.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="/webhook-setup"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
</div>
</div>
<div class="server-status-wrap">
<div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
<div class="server-status-tip">OxideSLOC is running as a local server in your terminal.<br>Close the terminal window to stop the server.</div>
</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">{{ message }}</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>
<form method="post" action="/relocate-scan">
<input type="hidden" name="run_id" value="{{ run_id }}">
<input type="hidden" name="redirect_url" value="{{ redirect_url }}">
<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="submit" class="btn-primary" style="border:none;">Restore Scan</button>
</div>
</form>
</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>
<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'));var placed=[];function tooClose(t,l){for(var i=0;i<placed.length;i++){if(Math.abs(placed[i][0]-t)<16&&Math.abs(placed[i][1]-l)<12)return true;}return false;}function pick(lb){for(var a=0;a<50;a++){var t=Math.random()*88+2,l=lb?Math.random()*24+1:Math.random()*24+74;if(!tooClose(t,l)){placed.push([t,l]);return[t,l];}}var t=Math.random()*88+2,l=lb?Math.random()*24+1:Math.random()*24+74;placed.push([t,l]);return[t,l];}var half=Math.floor(wms.length/2);wms.forEach(function(img,i){var pos=pick(i<half),w=Math.floor(Math.random()*60+80),rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2),dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;img.style.animation='wmFade '+dur+'s ease-in-out -'+delay+'s infinite alternate';});})();
</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 btn=document.getElementById('browse-relocate-btn');
if(!btn)return;
btn.addEventListener('click',function(){
btn.disabled=true;btn.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.json();})
.then(function(d){
btn.disabled=false;btn.textContent='Browse…';
if(d&&d.selected_path&&inp)inp.value=d.selected_path;
})
.catch(function(){btn.disabled=false;btn.textContent='Browse…';});
});
}());
</script>
</body>
</html>
"##,
ext = "html"
)]
struct RelocateScanTemplate {
message: String,
run_id: String,
folder_hint: String,
redirect_url: String,
server_mode: bool,
csp_nonce: String,
}
// ── 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);}
.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{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}
.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;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: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;}
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="/webhook-setup"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
</div>
</div>
<div class="server-status-wrap">
<div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
<div class="server-status-tip">OxideSLOC is running as a local server in your terminal.<br>Close the terminal window to stop the server.</div>
</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">
{% 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 %}
</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="/view-reports">
<button type="submit" class="btn">↻ Refresh</button>
</form>
</div>
</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">Files excluded by policy rules (vendor, generated, binary, lockfiles, etc.) in the most recent scan</div><div class="stat-chip-val" id="agg-skipped">—</div><div class="stat-chip-label">Latest files skipped</div></div>
</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>
</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 project…">
<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">
oxide-sloc v{{ version }} — local code analysis - metrics, history and reports ·
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 Math.round(v/1e3)+'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);
setChipVal('agg-skipped', first.dataset.skipped);
}
// ── 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.json(); })
.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>
</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,
}
// ── 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);}
.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{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}
.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) td{background:var(--surface-2);}
tr{cursor:pointer;}
.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;}
.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;}
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="/webhook-setup"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
</div>
</div>
<div class="server-status-wrap">
<div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
<div class="server-status-tip">OxideSLOC is running as a local server in your terminal.<br>Close the terminal window to stop the server.</div>
</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">
{% 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 %}
</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="/compare-scans">
<button type="submit" class="btn">↻ Refresh</button>
</form>
</div>
</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 exactly two to compare their metrics side-by-side.</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/2</span>
</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 project…">
<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>
Click any two rows to select them, then press <strong>Compare</strong> to view the scan delta.
</div>
</div>
{% endif %}
<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-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">
oxide-sloc v{{ version }} — local code analysis - metrics, history and reports ·
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; });
// ── 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 Math.round(v/1e3)+'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();
// ── Row selection state ───────────────────────────────────────────────
var selected = [];
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 + '/2';
}
function toggleRow(row) {
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 = '';
} else {
if (selected.length >= 2) return;
selected.push(vid);
row.classList.add('selected');
}
selected.forEach(function(v, i) {
var b = document.getElementById('badge-' + v);
if (b) b.textContent = i + 1;
});
updateCompareBtn();
buildScopePanel();
}
// ── 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 both 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;
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;
}
// ── 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.json(); })
.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>
</body>
</html>
"##,
ext = "html"
)]
struct CompareSelectTemplate {
version: &'static str,
entries: Vec<HistoryEntryRow>,
total_scans: usize,
watched_dirs: Vec<String>,
csp_nonce: String,
}
// ── 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);}
.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{max-width:1720px;margin:0 auto;padding:18px 24px 40px;position:relative;z-index:1;}
.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;}
.dc-tip{display:none;position:absolute;top:calc(100% + 8px);left:50%;transform:translateX(-50%);z-index:200;background:rgba(20,12,8,0.96);color:rgba(255,255,255,0.92);border-radius:10px;padding:10px 14px;font-size:11.5px;font-weight:500;line-height:1.55;width:230px;box-shadow:0 8px 24px rgba(0,0,0,0.32);pointer-events:none;border:1px solid rgba(255,255,255,0.10);text-transform:none;letter-spacing:0;}
.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;}
.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{padding:6px 14px;border-radius:8px;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:13px;font-weight:700;cursor:pointer;transition:background .12s ease;white-space:nowrap;}
.btn-reset:hover{background:var(--line);}
.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);padding:8px 10px;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:9px 10px;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.row-added td{background:rgba(26,143,71,0.06);}
tr.row-removed td{background:rgba(179,59,59,0.06);opacity:.85;}
tr.row-modified td{background:rgba(146,96,0,0.05);}
tr.row-unchanged td{opacity:.6;}
.file-path{font-family:ui-monospace,monospace;font-size:12px;white-space:nowrap;overflow:visible;text-overflow:unset;}
.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:4px;white-space:nowrap;color:var(--muted);font-size:12px;}
.from-to strong{color:var(--text);}
.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: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;}
.ic-dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}
.ic-cb{cursor:pointer;transition:opacity .15s,filter .15s;}.ic-cb:hover{opacity:.72;filter:brightness(1.1);}
#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="/webhook-setup"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
</div>
</div>
<div class="server-status-wrap">
<div class="nav-pill server-online-pill"><span class="status-dot"></span>Online</div>
<div class="server-status-tip">OxideSLOC is running as a local server in your terminal.<br>Close the terminal window to stop the server.</div>
</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;">
{% 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>
<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>
{% 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">{{ author }}</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">{{ author }}</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. Excludes comments and blanks. Positive delta = more code written.</div>
<div class="delta-card-label">Code lines</div>
<div class="delta-card-from">Before: {{ baseline_code }}</div>
<div class="delta-card-to">{{ current_code }}</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. 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 }}</div>
<div class="delta-card-to">{{ current_files }}</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. 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 }}</div>
<div class="delta-card-to">{{ current_comments }}</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>
<div class="delta-card delta-card-wide">
<div class="dc-tip">Per-file breakdown. Modified = at least one count changed. Unchanged = identical counts in both scans. Added/Removed = only in one scan.</div>
<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. 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. 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. Formula: (lines added + lines removed) ÷ baseline code lines × 100%. Above 20% = high activity, 5–20% = normal velocity, below 5% = stable.</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. Switch to Full scan to compare against the parent repository.{% else %}Triggered when net code growth exceeds 20% of the baseline. This often signals a large feature branch, a bulk import, or a generated-file inclusion. Review the file-level delta below to confirm scope.{% endif %}</div>
<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">
<h2>Scan Delta Charts</h2>
<div class="ic-grid">
<div class="ic-card">
<div class="ic-card-h2">Code Metrics — Baseline vs Current</div>
<div class="ic-leg"><span><span class="ic-dot" style="background:#93C5FD"></span><span style="color:#2563EB;font-weight:600">Code Lines</span></span><span><span class="ic-dot" style="background:#C4B5FD"></span><span style="color:#7C3AED;font-weight:600">Files</span></span><span><span class="ic-dot" style="background:#6EE7B7"></span><span style="color:#0D9488;font-weight:600">Comments</span></span><span style="font-size:10px;color:var(--muted)">(faded = before)</span></div>
<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">
<h2>File-level delta</h2>
<div class="filter-tabs-row">
<div class="filter-tabs">
<button class="tab-btn tab-all active" data-filter="all">All</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:10px;">
<span class="delta-note">* Δ = delta (change from baseline → current)</span>
<div class="export-group">
<button type="button" class="btn-reset" 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>
<button type="button" class="export-btn" id="delta-charts-btn">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><line x1="2" y1="20" x2="22" y2="20"/><rect x="3" y="13" width="4" height="7" rx="1"/><rect x="10" y="7" width="4" height="13" rx="1"/><rect x="17" y="2" width="4" height="18" rx="1"/></svg>
Charts
</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 class="file-path" title="{{ row.relative_path }}">{{ row.relative_path }}</td>
<td class="hide-sm">{{ row.language }}</td>
<td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
<td><span class="from-to"><strong>{{ row.baseline_code }}</strong><span>→</span><strong>{{ row.current_code }}</strong></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-info"></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>
<span class="per-page-label" id="pg-range-label"></span>
</div>
</div>
</section>
</div>
<div id="ic-tt"></div>
<footer class="site-footer">
oxide-sloc v{{ version }} — local code analysis - metrics, history and reports ·
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)).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 : 'No results';
var info = document.getElementById('pg-info');
if (info) info.textContent = totalPages > 1 ? 'Page ' + deltaCurrPage + ' of ' + totalPages : '';
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 === '—') 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();
// ── 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(); });
var chartsBtn = document.getElementById('delta-charts-btn');
if (chartsBtn) chartsBtn.addEventListener('click', function() { window.exportDeltaCharts(); });
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 — Scan Delta Report',1));
r1(s1(0,proj,2));
r1(s1(0,sd.bts+' → '+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 }}'};
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+'_summary.csv',_summaryHdrs,getSummaryExportRows());};
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 Math.round(v/1e3)+'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=160,c1mt=20,c1mb=24,c1ml=14,c1mr=14;
var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=52,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="14" 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-3)+'" 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-3)+'" 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=48,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=14+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+21)+'" 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+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
if(bw>=52){
c2+='<text x="'+px(bx+bw/2)+'" y="'+(y+25)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="12" font-weight="700" fill="white">'+esc(vStr)+'</text>';
}else{
var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
c2+='<text x="'+vx2+'" y="'+(y+25)+'" text-anchor="'+anc2+'" font-family="Inter,Calibri,Arial" font-size="12" font-weight="700" fill="'+col+'">'+esc(vStr)+'</text>';
}
});
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 — wider aspect ratio to avoid tall scaling
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 cx4=110,cy4=100,Ro=84,Ri=46,C4W=480,C4H=210;
var c4='<svg viewBox="0 0 '+C4W+' '+C4H+'" width="100%" 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){c4+='<rect x="234" y="'+(16+i*44)+'" width="14" height="14" fill="'+s.c+'" rx="2"/><text x="252" y="'+(27+i*44)+'" font-family="Inter,Calibri,Arial" font-size="12" fill="#333">'+esc(s.l)+': '+fmt(s.v)+'</text>';});
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());};
// ── 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';};
(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 Math.round(v/1e3)+'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" onmouseover="icTT(event,\''+jsq(l)+'\',\''+jsq(v)+'\')" onmouseout="icHT()" onmousemove="icMT(event)"';}
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=160,c1mt=20,c1mb=24,c1ml=14,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=52,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="14" 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-3)+'" 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-3)+'" 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=48,C2H=mets.length*rH+28,c2LW=144,c2RP=18,cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
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=14+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2),col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw,sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
c2+='<text x="'+(c2LW-8)+'" y="'+(y+21)+'" text-anchor="end" font-family="Inter,Calibri,Arial" font-size="12" font-weight="600" fill="'+m.mc+'">'+esc(m.l)+'</text>';
c2+='<rect'+btt(m.l,'Delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+7)+'" width="'+px(bw)+'" height="26" fill="'+col+'" rx="3"/>';
if(bw>=52){c2+='<text x="'+px(bx+bw/2)+'" y="'+(y+25)+'" text-anchor="middle" font-family="Inter,Calibri,Arial" font-size="12" font-weight="700" fill="white">'+esc(vStr)+'</text>';}
else{var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';c2+='<text x="'+vx2+'" y="'+(y+25)+'" text-anchor="'+anc2+'" font-family="Inter,Calibri,Arial" font-size="12" font-weight="700" fill="'+col+'">'+esc(vStr)+'</text>';}
});
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
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 cx4=110,cy4=100,Ro=84,Ri=46,C4W=480,C4H=210,c4='<svg viewBox="0 0 '+C4W+' '+C4H+'" width="100%" xmlns="http://www.w3.org/2000/svg">',ang=-Math.PI/2;
if(segs.length===1){
// Single segment — SVG arc degenerates at 360°; use concentric circles instead
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){c4+='<rect x="234" y="'+(16+i*44)+'" width="14" height="14" fill="'+s.c+'" rx="2"/><text x="252" y="'+(27+i*44)+'" font-family="Inter,Calibri,Arial" font-size="12" fill="#444">'+esc(s.l)+': '+fmt(s.v)+'</text>';});
c4+='</svg>';
var e1=document.getElementById('ic-c1');if(e1)e1.innerHTML=c1;
var e2=document.getElementById('ic-c2');if(e2)e2.innerHTML=c2;
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>';
var e4=document.getElementById('ic-c4');if(e4)e4.innerHTML=c4;
var lc=document.getElementById('ic-lang-card');if(lc)lc.style.display=langs.length?'':'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>
</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,
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,
}
// ── 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"
)]
struct LoginTemplate {
csp_nonce: String,
has_error: bool,
next_url: String,
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);}
.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 60px;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="/webhook-setup"><svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path></svg>Integrations</a>
</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>
<!-- 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">
oxide-sloc v{{ version }} — local code analysis - metrics, history and reports ·
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,
}