// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Nima Shafie <nimzshafie@gmail.com>
#![allow(clippy::multiple_crate_versions)]
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 git_browser;
pub(crate) mod git_webhook;
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,
};
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 {
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())
})
}
}
/// 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,
},
/// run_id so the status endpoint can redirect to /runs/{run_id}/result.
Complete {
run_id: String,
},
Failed {
message: String,
},
}
#[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,
}
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,
}
fn build_router(state: AppState) -> Router {
let protected = Router::new()
.route("/", get(splash))
.route("/scan-setup", get(scan_setup_handler))
.route("/scan", get(index))
.route("/analyze", post(analyze_handler))
.route("/preview", get(preview_handler))
.route("/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("/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/{run_id}/{artifact}", get(artifact_handler))
.route("/api/metrics/latest", get(api_metrics_latest_handler))
.route("/api/metrics/{run_id}", get(api_metrics_run_handler))
.route("/api/project-history", get(project_history_handler))
.route("/api/runs/{wait_id}/status", get(async_run_status_handler))
.route("/api/runs/{run_id}/pdf-status", get(pdf_status_handler))
.route("/runs/{run_id}/result", 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))
// ── Webhook + schedule management ──────────────────────────────────────
.route("/webhook-setup", get(git_webhook::webhook_setup_handler))
.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_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_secs(60),
600,
10,
Duration::from_secs(3600),
)),
trust_proxy: false,
git_clones_dir: tmp.join("git-clones"),
schedules: Arc::new(Mutex::new(ScheduleStore::default())),
schedules_path: tmp.join("schedules.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<()> {
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),
));
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 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,
};
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}");
}
});
}
}
}
}
async fn require_api_key(
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 any_credential_provided =
auth_header.is_some() || x_api_key.is_some() || session_cookie.is_some();
let valid = [&auth_header, &x_api_key, &session_cookie]
.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
} else {
"/"
};
let valid = state.api_keys.iter().any(|expected| {
use secrecy::ExposeSecret;
ct_eq(&form.key, expected.expose_secret())
});
if valid {
let secure_flag = if state.tls_enabled { "; Secure" } else { "" };
let cookie_value = format!(
"sloc_session={}; Path=/; HttpOnly; SameSite=Strict{}",
form.key, 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_pst(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 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>,
generate_html: Option<String>,
generate_pdf: Option<String>,
include_globs: Option<String>,
exclude_globs: Option<String>,
submodule_breakdown: 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 title = match query.kind.as_deref() {
Some("output") => "Select output directory",
Some("reports") => "Select folder containing saved reports",
_ => "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 = 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,
},
git_branch: None,
git_commit: None,
git_author: None,
git_tags: None,
git_commit_date: None,
}
}
/// 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,
)
}
#[derive(Deserialize)]
struct LocateReportsDirForm {
folder_path: String,
}
async fn locate_reports_dir_handler(
State(state): State<AppState>,
axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
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 locate_report_error("Folder not found or path is invalid.", &csp_nonce),
};
if !folder.is_dir() {
return locate_report_error("Selected path is not a directory.", &csp_nonce);
}
// Collect result.json candidates: the folder itself and one level of subdirectories.
let mut candidates: Vec<PathBuf> = Vec::new();
let top = folder.join("result.json");
if top.exists() {
candidates.push(top);
}
if let Ok(dir_entries) = fs::read_dir(&folder) {
for entry in dir_entries.flatten() {
let sub = entry.path();
if sub.is_dir() {
let j = sub.join("result.json");
if j.exists() {
candidates.push(j);
}
}
}
}
if candidates.is_empty() {
return locate_report_error(
"No result.json files found in the selected folder or its subdirectories.",
&csp_nonce,
);
}
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.
let already = reg.entries.iter().any(|e| {
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)
});
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 run = match read_json(&json_path) {
Ok(r) => r,
Err(_) => 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,
},
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_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 locate_report_error(
"No new reports were loaded. The selected folder may already be fully indexed, \
or the result.json files could not be parsed.",
&csp_nonce,
);
}
axum::response::Redirect::to(&format!("/view-reports?linked={linked_count}")).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(),
};
let Ok(canonical) = fs::canonicalize(raw) else {
return (StatusCode::BAD_REQUEST, "path not found").into_response();
};
// Must be a directory (or a file whose parent directory we open).
let target = if canonical.is_file() {
match canonical.parent() {
Some(p) => p.to_path_buf(),
None => return (StatusCode::BAD_REQUEST, "path has no parent").into_response(),
}
} else if canonical.is_dir() {
canonical
} else {
// Block special devices, pipes, sockets, etc.
return (StatusCode::BAD_REQUEST, "path is not a file or directory").into_response();
};
#[cfg(target_os = "windows")]
let _ = std::process::Command::new("explorer.exe")
.arg(&target)
.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())
)),
}
}
/// 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,
}
}
/// 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_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();
}
}
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");
}
/// 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/{run_id}/{artifact_key}"))
} 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(
State(state): State<AppState>,
axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
Form(form): Form<AnalyzeForm>,
) -> impl IntoResponse {
let Ok(_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());
// 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 mut runs = state.async_runs.lock().await;
runs.insert(
wait_id.clone(),
AsyncRunState::Running {
started_at: std::time::Instant::now(),
},
);
}
tokio::spawn(async move {
// Hold the permit for the lifetime of the background task.
let _permit = _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 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");
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")?;
let html = render_html(&run)?;
Ok((run, html))
})
.await
.map_err(|err| anyhow::anyhow!(err.to_string()))
.and_then(|result| result);
let (run, report_html) = match analysis_result {
Ok(v) => v,
Err(err) => {
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: "Analysis failed. Check that the path exists and is readable."
.to_string(),
},
);
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(),
},
);
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/{run_id}/result.
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 },
}
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_secs(7200) {
let mut runs = state.async_runs.lock().await;
runs.insert(
wait_id,
AsyncRunState::Failed {
message: "Analysis timed out after 2 hours.".to_string(),
},
);
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()
}
}
}
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 = match &artifacts.json_path {
Some(p) => p.clone(),
None => {
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 run = match read_json(&json_path) {
Ok(r) => r,
Err(e) => {
let html = ErrorTemplate {
message: format!("Could not load scan result: {e}"),
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>Load error.</pre>".to_string());
return (StatusCode::INTERNAL_SERVER_ERROR, Html(html)).into_response();
}
};
render_result_page(&run, &artifacts, &run_id, &csp_nonce)
}
#[allow(clippy::too_many_lines)]
fn render_result_page(
run: &AnalysisRun,
artifacts: &RunArtifacts,
run_id: &str,
csp_nonce: &str,
) -> 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 language_rows = run
.totals_by_language
.iter()
.map(|row| LanguageSummaryRow {
language: row.language.display_name().to_string(),
files: row.files,
physical: row.total_physical_lines,
code: row.code_lines,
comments: row.comment_lines,
blank: row.blank_lines,
mixed: row.mixed_lines_separate,
functions: row.functions,
classes: row.classes,
variables: row.variables,
imports: row.imports,
})
.collect::<Vec<_>>();
let files_analyzed = run.per_file_records.len() as u64;
let files_skipped = run.skipped_file_records.len() as u64;
let physical_lines = language_rows.iter().map(|r| r.physical).sum::<u64>();
let code_lines = language_rows.iter().map(|r| r.code).sum::<u64>();
let comment_lines = language_rows.iter().map(|r| r.comments).sum::<u64>();
let blank_lines = language_rows.iter().map(|r| r.blank).sum::<u64>();
let mixed_lines = language_rows.iter().map(|r| r.mixed).sum::<u64>();
let functions = language_rows.iter().map(|r| r.functions).sum::<u64>();
let classes = language_rows.iter().map(|r| r.classes).sum::<u64>();
let variables = language_rows.iter().map(|r| r.variables).sum::<u64>();
let imports = language_rows.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/{run_id}/html")),
pdf_url: artifacts
.pdf_path
.as_ref()
.map(|_| format!("/runs/{run_id}/pdf")),
json_url: artifacts
.json_path
.as_ref()
.map(|_| format!("/runs/{run_id}/json")),
html_download_url: artifacts
.html_path
.as_ref()
.map(|_| format!("/runs/{run_id}/html?download=1")),
pdf_download_url: artifacts
.pdf_path
.as_ref()
.map(|_| format!("/runs/{run_id}/pdf?download=1")),
json_download_url: artifacts
.json_path
.as_ref()
.map(|_| format!("/runs/{run_id}/json?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)),
language_rows,
prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_pst(e.timestamp_utc)),
prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
prev_fa_str,
prev_fs_str,
prev_pl_str,
prev_cl_str,
prev_cml_str,
prev_bl_str,
delta_fa_str,
delta_fa_class,
delta_fs_str,
delta_fs_class,
delta_pl_str,
delta_pl_class,
delta_cl_str,
delta_cl_class,
delta_cml_str,
delta_cml_class,
delta_bl_str,
delta_bl_class,
delta_lines_added,
delta_lines_removed,
delta_lines_net_str,
delta_lines_net_class,
delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
delta_unmodified_lines: scan_delta.as_ref().map(|d| {
d.file_deltas
.iter()
.filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
.map(|f| {
#[allow(clippy::cast_sign_loss)]
let n = f.current_code as u64;
n
})
.sum()
}),
git_branch: git_branch.clone(),
git_commit: git_commit.clone(),
git_author: git_author.clone(),
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()
.map(|p| !p.exists())
.unwrap_or(false),
scan_config_url: format!("/runs/{run_id}/scan-config"),
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":{}}}"#,
name, l.code_lines, l.comment_lines, l.blank_lines,
)
})
.collect();
format!("[{}]", entries.join(","))
},
csp_nonce: csp_nonce.to_owned(),
};
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.map(|p| p.exists()).unwrap_or(false);
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(
State(state): State<AppState>,
axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
AxumPath((run_id, artifact)): 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 error_html = ErrorTemplate {
message: format!(
"Report not found. Run ID {} is not in the scan history. \
The report may have been deleted, or this is an old run from \
before the scan registry was introduced.",
&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(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/{run_id}/html")),
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:#b85d33;--nav-2:#7a371b;--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;}}\
.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;margin-top:2px;line-height:1.2;}}\
.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 analysis workbench</div>\
</div>\
</a>\
<div class=\"nav-right\">\
<a class=\"nav-pill\" href=\"/\">Home</a>\
<a class=\"nav-pill\" href=\"/view-reports\">View Reports</a>\
<button type=\"button\" class=\"theme-toggle\" id=\"theme-toggle\" aria-label=\"Toggle theme\">\
<svg class=\"icon-moon\" viewBox=\"0 0 24 24\"><path d=\"M20 15.5A8.5 8.5 0 1 1 12.5 4 6.7 6.7 0 0 0 20 15.5Z\"></path></svg>\
<svg class=\"icon-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4.2\"></circle>\
<path d=\"M12 2.5v2.2M12 19.3v2.2M21.5 12h-2.2M4.7 12H2.5M18.9 5.1l-1.6 1.6M6.7 17.3l-1.6 1.6M18.9 18.9l-1.6-1.6M6.7 6.7 5.1 5.1\"></path></svg>\
</button>\
</div>\
</div></div>\
<div class=\"page\"><div class=\"panel\">\
<div class=\"spin-ring\"></div>\
<h1>Generating PDF\u{2026}</h1>\
<p>The PDF is being 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/{run_id}/pdf\">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(|p| p.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,
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,
}
fn fmt_pst(dt: chrono::DateTime<chrono::Utc>) -> String {
dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset is always 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_pst(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_pst(e.timestamp_utc),
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>,
}
async fn history_handler(
State(state): State<AppState>,
axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
Query(query): Query<HistoryQuery>,
) -> impl IntoResponse {
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 template = HistoryTemplate {
version: env!("CARGO_PKG_VERSION"),
entries,
total_scans,
linked_count,
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 {
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,
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",
}
}
fn fmt_pct(delta: i64, baseline: u64) -> String {
if baseline == 0 {
return "—".to_string();
}
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(
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 baseline_run = match read_json(base_json) {
Ok(r) => r,
Err(e) => {
let message = if state.server_mode {
"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()
} else {
format!(
"Could not load baseline scan data.\n\nPath: {}\n\nError: {e}\n\n\
The scan output folder may have been moved, renamed, or deleted. \
Re-running the analysis for this project will create fresh comparison data.",
base_json.display()
)
};
let html = ErrorTemplate {
message,
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 current_run = match read_json(curr_json) {
Ok(r) => r,
Err(e) => {
let message = if state.server_mode {
"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()
} else {
format!(
"Could not load current scan data.\n\nPath: {}\n\nError: {e}\n\n\
The scan output folder may have been moved, renamed, or deleted. \
Re-running the analysis for this project will create fresh comparison data.",
curr_json.display()
)
};
let html = ErrorTemplate {
message,
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 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.clone();
let mut c = current_run.clone();
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.clone();
let mut c = current_run.clone();
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;
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
};
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_pst(baseline_entry.timestamp_utc),
current_timestamp: fmt_pst(current_entry.timestamp_utc),
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_pst(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()
}
// ── 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(|e| e.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())
})
}
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(PathBuf::from)
.unwrap_or_else(|_| output_root.join("git-clones"))
}
/// 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()];
config.reporting.report_title = label.to_owned();
let run = analyze(&config, "git")?;
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.clone()
} 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,
},
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(),
}
}
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))
}
#[allow(clippy::too_many_lines)]
fn build_preview_html(
root: &Path,
include_patterns: &[String],
exclude_patterns: &[String],
) -> Result<String> {
if !root.exists() {
return Ok(format!(
r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
escape_html(&display_path(root))
));
}
let _selected = display_path(root);
let mut stats = PreviewStats::default();
let mut rows = Vec::new();
let mut languages = Vec::new();
let mut budget = PreviewBudget {
shown: 0,
max_entries: 600,
max_depth: 9,
};
let mut next_row_id = 1usize;
let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
|| root.to_string_lossy().into_owned(),
std::string::ToString::to_string,
);
let root_modified = root
.metadata()
.ok()
.and_then(|meta| meta.modified().ok())
.map_or_else(|| "-".to_string(), format_system_time);
rows.push(PreviewRow {
row_id: 0,
parent_row_id: None,
depth: 0,
name: format!("{root_name}/"),
kind: PreviewKind::Dir,
is_dir: true,
language: None,
modified: root_modified,
type_label: "Directory".to_string(),
});
collect_preview_rows(
root,
root,
0,
Some(0),
&mut next_row_id,
&mut budget,
&mut stats,
&mut rows,
&mut languages,
include_patterns,
exclude_patterns,
)?;
let mut out = String::new();
out.push_str(r#"<div class="explorer-wrap">"#);
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>");
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(
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 LanguageSummaryRow {
language: String,
files: u64,
physical: u64,
code: u64,
comments: u64,
blank: u64,
mixed: u64,
functions: u64,
classes: u64,
variables: u64,
imports: u64,
}
#[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: hidden; 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: 1fr auto 1fr; 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: wrap; }
.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); 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; }
.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); }
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; }
.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:center; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
.ws-stat { display:flex; flex-direction:column; 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); }
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-lang-tooltip { display:none; position:absolute; top:calc(100% + 8px); left:0; z-index:200; 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-badge: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:9px; }
.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; align-items:center; gap:8px; flex-wrap:wrap; 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: 1fr 1px auto; gap:0; align-items:stretch; }
.path-scope-grid .input-group { width:100%; align-self:start; }
.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; }
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); }
.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: 218px minmax(0, 1fr); gap: 18px; align-items:stretch; min-height: calc(100vh - 57px); }
.layout[data-active-step="4"] { align-items: start; min-height: auto; }
.side-stack { display:grid; gap: 16px; align-items:start; align-self: stretch; width: 218px; max-width: 218px; }
.step-nav { padding: 20px 16px; position: sticky; top: 57px; z-index: 25; }
.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:12px; border:none; background:transparent; border-radius: 12px; padding: 14px 12px; color: var(--text); cursor:pointer; text-align:left; font-size:15px; font-weight:700; 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; }
.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; }
.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: 22px; 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); }
.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); }
.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:stretch; 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; }
.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: 28px; }
.counting-intro { margin-bottom: 8px; max-width: none; }
.ieee-note { margin-bottom: 22px; padding: 9px 14px; border-left: 3px solid var(--oxide); background: linear-gradient(180deg, rgba(184,93,51,0.07), transparent), var(--surface-2); border-radius: 0 8px 8px 0; font-size: 13px; color: var(--muted); line-height: 1.5; font-style: italic; }
.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; }
.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; }
.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-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: 560px; 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: 9px 0; }
.tree-name-cell { display:flex; align-items:center; gap: 10px; padding-left: calc(var(--depth) * 18px + 8px); position: relative; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; min-width:0; }
.tree-toggle { width: 28px; height: 28px; display:inline-flex; align-items:center; justify-content:center; border:none; background: var(--surface-2); color: var(--muted-2); cursor:pointer; font-size: 18px; line-height: 1; flex:0 0 28px; border-radius: 8px; 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: 28px; text-align:center; flex: 0 0 28px; font-size: 14px; }
.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: 13px; }
.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; }
.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(560px, 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: 28px 32px; }
.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:12px;margin-bottom:16px; }
.lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:10px 16px;flex:0 0 auto; }
.lc-metric-label { font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:3px; }
.lc-metric-value { font-size:1.05rem;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-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; }
.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;}.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 analysis workbench</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>
<a class="nav-pill" href="/view-reports">View Reports</a>
<a class="nav-pill" href="/compare-scans">Compare Scans</a>
<div class="nav-dropdown">
<button class="nav-dropdown-btn" type="button">Git Tools <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></button>
<div class="nav-dropdown-menu">
<a href="/git-browser"><svg viewBox="0 0 24 24"><polyline points="16 18 22 12 16 6"></polyline><polyline points="8 6 2 12 8 18"></polyline></svg>Git Browser</a>
<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>Webhooks</a>
</div>
</div>
<div class="server-status-wrap">
<div class="nav-pill server-online-pill"><span class="status-dot"></span>Server 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="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"><span class="lc-dot"></span>Analysis running</div>
<h2 class="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">
<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"><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-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>
</div>
</div>
<div class="page">
<div class="workbench-strip">
<div class="workbench-box wb-stats">
<div class="wb-stats-header">
<span class="wb-stats-title">Analysis session</span>
</div>
<div class="ws-left">
<div class="ws-stat">
<span class="ws-label">Analyzers</span>
<span class="ws-value">
<span class="ws-badge">41 languages
<div class="ws-lang-tooltip">
<div class="ws-lang-tooltip-hdr">41 supported languages</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>
</span>
</span>
</div>
<div class="ws-divider"></div>
<div class="ws-stat"><span class="ws-label">Mode</span><span class="ws-value">Localhost workbench</span></div>
<div class="ws-divider"></div>
<div class="ws-stat ws-stat-clamp"><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">
<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">
<div class="ws-history-label">Scan history</div>
<div class="ws-history-inner">
<div class="ws-mini-box ws-mini-box-sm">
<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">
<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">
<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></button>
<button type="button" class="step-button" data-step-target="2"><span class="step-num">2</span><span>Counting rules</span></button>
<button type="button" class="step-button" data-step-target="3"><span class="step-num">3</span><span>Outputs and reports</span></button>
<button type="button" class="step-button" data-step-target="4"><span class="step-num">4</span><span>Review and run</span></button>
<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="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>
</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">
<div class="input-group">
{% if !git_repo.is_empty() %}
<input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input" required />
<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="tmp-sloc" 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>
<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="hint">Browse opens the native folder picker through the Rust backend, so you do not need to type local paths manually.</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>
<div style="height:1px;background:var(--line);margin:28px 0;"></div>
<div id="preview-panel" style="margin-top:0;">
<div class="preview-error">Loading preview...</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 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;">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="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-inline-row" style="align-items:start;">
<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 style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:12px;">
<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</div>
<h4>Comment and blank-line basics</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 mixed-line policy above only affects lines where executable code and comment text share the same line.</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">What these settings change</div>
<h4>Lines on the boundary</h4>
<div class="advanced-rule-description">The rules on this page only affect lines that live on the boundary between code and comments. A line like <code style="font-size:12px;">x = 1 # counter</code> is the boundary case — it contains both executable code and inline comment text. Every other category is always counted the same regardless of these settings.</div>
</div>
</div>
</div>
<div class="wizard-actions">
<div class="left">
<button type="button" class="secondary prev-step" data-prev="1">Back</button>
</div>
<div class="right">
<button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
</div>
</div>
</div>
<div class="wizard-step" data-step="3">
<div class="section">
<div class="section-kicker">Step 3</div>
<h2>Output and report identity</h2>
<p class="card-subtitle step3-subtitle" style="white-space:nowrap;">Choose where generated files should be saved, what the exported report title should be, and which artifact bundle fits your workflow.</p>
<div class="preset-inline-row" style="align-items:start;">
<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-inline-row" style="align-items:start;">
<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="tmp-sloc" 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 workbench header while you configure the run. It defaults to the last folder name of the selected project path.
</div>
</div>
</div>
<div class="section">
<div class="section-kicker">Artifacts</div>
<div class="artifact-grid">
<div class="artifact-card selected" data-artifact="html">
<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">
<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" data-artifact="json" style="opacity:0.75;pointer-events:none;">
<div class="marker" style="background:var(--oxide);border-color:var(--oxide);color:#fff;">✓</div>
<div class="artifact-icon">J</div>
<h4>JSON result <span style="font-size:11px;font-weight:700;color:var(--oxide-2);">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 class="hint" style="margin-top:16px;">Artifact cards are selectable. Presets above can also toggle them for common workflows.</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 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");
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"].forEach(function(id) {
var el = document.getElementById(id);
if (el) el.classList.add("hidden");
});
var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
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"].forEach(function(id) {
var el = document.getElementById(id);
if (el) el.classList.add("hidden");
});
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;
function lcSetPhase(txt) { var el = document.getElementById("lc-phase"); if (el) el.textContent = txt; }
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/" + encodeURIComponent(data.run_id) + "/result";
} else if (data.state === "failed") {
lcShowError(data.message);
} 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; }
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 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);
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 || "tmp-sloc");
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, json: false };
if (artifactPreset.value === "review") { enabled.html = true; enabled.pdf = true; }
if (artifactPreset.value === "full") { enabled.html = true; enabled.pdf = true; enabled.json = true; }
if (artifactPreset.value === "html_only") { enabled.html = true; }
if (artifactPreset.value === "machine") { enabled.json = true; enabled.html = true; }
artifactCards.forEach(function (card) {
var artifact = card.getAttribute("data-artifact");
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 || "tmp-sloc"; }
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 || "tmp-sloc") + "</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.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 || "tmp-sloc")) + "</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;
}
buttons.forEach(function (button) {
button.addEventListener("click", function () {
var filterValue = button.getAttribute("data-filter") || "all";
if (filterValue === "reset-view") {
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 || "tmp-sloc";
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);
})
.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();
}
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")));
});
});
if (useSamplePath) {
useSamplePath.addEventListener("click", function () {
pathInput.value = "tmp-sloc";
updateReportTitleFromPath();
loadPreview();
});
}
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 (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
// ── 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 ? Number(data.last_scan_code_lines).toLocaleString() : "?") + " 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);
clearTimeout(historyTimer);
historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
if (previewTimer) clearTimeout(previewTimer);
previewTimer = setTimeout(loadPreview, 280);
}
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(); });
if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); });
artifactCards.forEach(function (card) {
card.addEventListener("click", function () {
toggleArtifactCard(card);
updateReview();
});
});
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>
<footer class="site-footer">
oxide-sloc v{{ version }} — local source line analysis workbench ·
Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
· <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
· <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
</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 — Source Line Analysis Workbench</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:#b85d33; --nav-2:#7a371b; --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: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;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;margin-top:2px;line-height:1.2;}
.nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
.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;}
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;}
.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:1100px;margin:0 auto;padding:48px 24px 16px;position:relative;z-index:1;}
.hero{text-align:center;margin-bottom:52px;}
.hero-logo{width:88px;height:97px;object-fit:contain;margin-bottom:6px;filter:drop-shadow(0 8px 22px rgba(184,93,51,0.30));animation:logoBob 3.6s ease-in-out infinite;}
@keyframes logoBob{0%,100%{transform:translateY(0) scale(1);}40%{transform:translateY(-18px) scale(1.07);}60%{transform:translateY(-14px) scale(1.05);}}
.hero-title{font-size:51px;font-weight:900;letter-spacing:-0.04em;margin:0 0 10px;
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;
animation:titleShimmer 4s linear infinite;}
@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:18px;color:var(--muted);line-height:1.6;max-width:600px;margin:0 auto;animation:fadeSlideUp 0.9s ease both;}
@keyframes fadeSlideUp{from{opacity:0;transform:translateY(18px);}to{opacity:1;transform:translateY(0);}}
.action-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;margin-bottom:32px;}
@media(max-width:900px){.action-grid{grid-template-columns:1fr 1fr;}}
@media(max-width:480px){.action-grid{grid-template-columns:1fr;}}
.action-card{display:flex;flex-direction:column;align-items:flex-start;padding:28px 26px 24px;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;}
@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:52px;height:52px;border-radius:16px;display:flex;align-items:center;justify-content:center;margin-bottom:18px;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:26px;height:26px;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:20px;font-weight:850;letter-spacing:-0.02em;margin:0 0 8px;}
.action-card-desc{font-size:14px;color:var(--muted);line-height:1.6;margin:0 0 20px;flex:1;}
.action-card-cta{display:inline-flex;align-items:center;gap:7px;font-size:13px;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:hover .action-card-cta{gap:12px;}
.divider{height:1px;background:var(--line);margin:40px 0;}
.info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:16px;}
@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:18px 20px;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:22px;font-weight:900;color:var(--oxide);}
body.dark-theme .info-chip-val{color:var(--oxide);}
.info-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
.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;}
.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:24px 28px;margin-bottom:32px;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;}.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">Source line analysis workbench</div></div>
</a>
<div class="nav-right">
<a class="nav-pill" href="/">Home</a>
<a class="nav-pill" href="/view-reports">View Reports</a>
<a class="nav-pill" href="/compare-scans">Compare Scans</a>
<div class="nav-dropdown">
<button class="nav-dropdown-btn" type="button">Git Tools <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></button>
<div class="nav-dropdown-menu">
<a href="/git-browser"><svg viewBox="0 0 24 24"><polyline points="16 18 22 12 16 6"></polyline><polyline points="8 6 2 12 8 18"></polyline></svg>Git Browser</a>
<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>Webhooks</a>
</div>
</div>
<div class="server-status-wrap">
{% if server_mode %}
<div class="nav-pill server-online-pill"><span class="status-dot"></span>LAN server</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>Running locally</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="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">
<img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
<h1 class="hero-title">OxideSLOC</h1>
<p class="hero-subtitle">A fast, self-contained source line analysis workbench. Count code, track history, and compare scan snapshots — no setup required.</p>
</div>
<div class="action-grid">
<a class="action-card scan" href="/scan-setup">
<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.</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>
</a>
<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 previously 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 scan builds to see a side-by-side delta — added, removed, and modified files with exact line-count changes.</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 git-tools" href="/git-browser">
<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 repository branches and commits in the Git Browser, or configure webhook triggers and automated scan schedules.</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>
</a>
</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="info-chip-val">41</div>
<div class="info-chip-label">Languages</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="info-chip-val">100%</div>
<div class="info-chip-label">Self-contained</div>
</div>
<div class="info-chip">
<div class="info-chip-tip">Self-contained HTML reports with<br>light/dark theme — share without a server</div>
<div class="info-chip-val">HTML</div>
<div class="info-chip-label">Exportable reports</div>
</div>
<div class="info-chip">
<div class="info-chip-tip">Detects .gitmodules and produces<br>per-submodule breakdowns automatically</div>
<div class="info-chip-val">Git</div>
<div class="info-chip-label">Submodule support</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="info-chip-val">IEEE</div>
<div class="info-chip-label">1045-1992</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 source line analysis workbench ·
Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
· <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
· <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
</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);
}
})();
})();
</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:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
--line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
--nav:#b85d33; --nav-2:#7a371b; --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;}
.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;margin-top:2px;line-height:1.2;}
.nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
.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;}
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;}
.page{max-width:960px;margin:0 auto;padding:40px 24px 64px;position:relative;z-index:1;}
.page-header{text-align:center;margin-bottom:32px;}
.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;}
.breadcrumb{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--muted);margin-bottom:28px;}
.breadcrumb a{color:var(--muted);text-decoration:none;} .breadcrumb a:hover{color:var(--oxide);}
.breadcrumb svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2;}
/* Cards */
.option-grid{display:flex;flex-direction:column;gap:16px;}
.option-card{background:var(--surface);border:1.5px solid var(--line-strong);border-radius:var(--radius);padding:22px 26px;box-shadow:var(--shadow);transition:border-color 0.18s ease,box-shadow 0.18s ease;}
.option-card:hover{border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
/* Two-column layout inside each card */
.card-body{display:grid;grid-template-columns:1fr 240px;gap:24px;align-items:center;}
.card-left{display:flex;align-items:flex-start;gap:16px;min-width:0;}
.option-icon{width:46px;height:46px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex:0 0 auto;}
.option-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
.option-icon.new-scan{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;box-shadow:0 6px 18px rgba(184,80,40,0.28);}
.option-icon.load-config{background:linear-gradient(135deg,#3b82f6,#1d4ed8);color:#fff;box-shadow:0 6px 18px rgba(59,130,246,0.28);}
.option-icon.rescan{background:linear-gradient(135deg,#8b5cf6,#6d28d9);color:#fff;box-shadow:0 6px 18px rgba(139,92,246,0.28);}
.card-text{min-width:0;}
.option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 4px;}
.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:11px 20px;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;}
.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;}.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">Source line analysis workbench</div></div>
</a>
<div class="nav-right">
<a class="nav-pill" href="/">Home</a>
<a class="nav-pill" href="/view-reports">View Reports</a>
<a class="nav-pill" href="/compare-scans">Compare Scans</a>
<div class="nav-dropdown">
<button class="nav-dropdown-btn" type="button">Git Tools <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></button>
<div class="nav-dropdown-menu">
<a href="/git-browser"><svg viewBox="0 0 24 24"><polyline points="16 18 22 12 16 6"></polyline><polyline points="8 6 2 12 8 18"></polyline></svg>Git Browser</a>
<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>Webhooks</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="breadcrumb">
<a href="/">Home</a>
<svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
<span>Scan Setup</span>
</div>
<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">
<div class="card-body">
<div class="card-left">
<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-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 line-counting modes with interactive examples</li>
<li>HTML, PDF, and JSON output — your choice</li>
<li>IEEE 1045-1992 compliant physical SLOC counting</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>
<!-- Option 2: Load from config file -->
<div class="option-card">
<div class="card-body">
<div class="card-left">
<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-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>
<!-- Option 3: Re-scan recent project -->
<div class="option-card" id="recent-card">
<div class="card-body">
<div class="card-left" style="grid-column:1/-1;">
<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-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>
<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>
<footer class="site-footer">
oxide-sloc v{{ version }} — local source line analysis workbench ·
Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
· <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
· <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
</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';
// 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>
</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: 1fr auto 1fr; 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: wrap; }
.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); 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; }
.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:6px 12px; text-align:center; min-width:80px; }
.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; }
.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); }
th:first-child, td:first-child { width: 28%; }
th { color: var(--muted); font-weight: 700; }
tr:last-child td { border-bottom: none; }
.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 { background: var(--success-bg); color: var(--success-text); }
.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; }
/* 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;}.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 analysis workbench</div>
</div>
</a>
<div class="nav-project-slot">
<div class="nav-project-pill"><span class="nav-project-label">Project</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>
<a class="nav-pill" href="/view-reports" style="text-decoration:none;">View Reports</a>
<a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
<div class="nav-dropdown">
<button class="nav-dropdown-btn" type="button">Git Tools <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></button>
<div class="nav-dropdown-menu">
<a href="/git-browser"><svg viewBox="0 0 24 24"><polyline points="16 18 22 12 16 6"></polyline><polyline points="8 6 2 12 8 18"></polyline></svg>Git Browser</a>
<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>Webhooks</a>
</div>
</div>
<div class="server-status-wrap">
<div class="nav-pill server-online-pill"><span class="status-dot"></span>Server 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="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">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 the local workbench.</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>
{% 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>
<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>
<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>
<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>
<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>
<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>
<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>
</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 %}
</div>
<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 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 %}
</div>
<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 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>
</div>
<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 !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:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
<table style="width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed;min-width:700px;">
<colgroup>
<col style="width:14%"><col style="width:40%">
<col style="width:6%"><col style="width:8%"><col style="width:6%">
<col style="width:8%"><col style="width:6%"><col style="width:12%">
</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;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Path</th>
<th style="padding:9px 10px;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;">Files</th>
<th style="padding:9px 10px;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;">Physical</th>
<th style="padding:9px 10px;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;">Code</th>
<th style="padding:9px 10px;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;">Comments</th>
<th style="padding:9px 10px;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;">Blank</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: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;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="{{ row.name }}"><strong>{{ row.name }}</strong></td>
<td style="padding:10px 14px;border-bottom:1px solid var(--line);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="{{ row.relative_path }}"><code style="font-size:12px;">{{ row.relative_path }}</code></td>
<td style="padding:10px 10px;border-bottom:1px solid var(--line);text-align:right;">{{ row.files_analyzed }}</td>
<td style="padding:10px 10px;border-bottom:1px solid var(--line);text-align:right;">{{ row.total_physical_lines }}</td>
<td style="padding:10px 10px;border-bottom:1px solid var(--line);text-align:right;">{{ row.code_lines }}</td>
<td style="padding:10px 10px;border-bottom:1px solid var(--line);text-align:right;">{{ row.comment_lines }}</td>
<td style="padding:10px 10px;border-bottom:1px solid var(--line);text-align:right;">{{ row.blank_lines }}</td>
<td style="padding:10px 14px;border-bottom:1px solid var(--line);text-align:center;">{% if let Some(url) = row.html_url %}<a class="button" href="{{ url }}" target="_blank" rel="noopener" style="font-size:12px;padding:6px 12px;min-height:0;">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>
<section class="panel" style="margin-bottom: 18px;">
<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 18px;"></div>
<table>
<thead>
<tr>
<th>Language</th>
<th>Files</th>
<th>Physical</th>
<th>Code</th>
<th>Comments</th>
<th>Blank</th>
<th>Mixed</th>
<th>Functions</th>
<th>Classes</th>
<th>Variables</th>
<th>Imports</th>
</tr>
</thead>
<tbody>
{% for row in language_rows %}
<tr>
<td>{{ row.language }}</td>
<td>{{ row.files }}</td>
<td>{{ row.physical }}</td>
<td>{{ row.code }}</td>
<td>{{ row.comments }}</td>
<td>{{ row.blank }}</td>
<td>{{ row.mixed }}</td>
<td>{{ row.functions }}</td>
<td>{{ row.classes }}</td>
<td>{{ row.variables }}</td>
<td>{{ row.imports }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
</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();
// ── 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){return Number(n).toLocaleString();}
function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
function px(n){return Math.round(n);}
var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
var cx=120,cy=120,Ro=100,Ri=54,DW=420,DH=Math.max(270,24+D.length*22);
var ds='<svg viewBox="0 0 '+DW+' '+DH+'" width="'+DW+'" height="'+DH+'" style="display:block;" xmlns="http://www.w3.org/2000/svg">';
if(D.length===1){
var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
ds+='<circle 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);
ds+='<path 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-6)+'" text-anchor="middle" font-family="'+FONT+'" font-size="22" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
ds+='<text x="'+cx+'" y="'+(cy+16)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
D.forEach(function(d,i){
var ly=12+i*22;
if(ly+16>DH)return;
ds+='<rect x="'+(cx+Ro+14)+'" y="'+ly+'" width="13" height="13" fill="'+(COLS[i%COLS.length])+'" rx="3"/>';
ds+='<text x="'+(cx+Ro+32)+'" y="'+(ly+11)+'" font-family="'+FONT+'" font-size="12" fill="#43342d">'+esc(d.lang)+'</text>';
});
ds+='</svg>';
var maxT=Math.max.apply(null,D.map(function(d){return d.code+d.comments+d.blanks;}))||1;
var LW=104,BW=280,rHb=30,bH=22,SH=D.length*rHb+36;
var bs='<svg viewBox="0 0 '+(LW+BW+62)+' '+SH+'" width="'+(LW+BW+62)+'" height="'+SH+'" style="display:block;" xmlns="http://www.w3.org/2000/svg">';
D.forEach(function(d,i){
var y=10+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)bs+='<rect x="'+px(x)+'" y="'+y+'" width="'+px(cW)+'" height="'+bH+'" fill="'+OX+'"/>';x+=cW;
if(cmW>0)bs+='<rect x="'+px(x)+'" y="'+y+'" width="'+px(cmW)+'" height="'+bH+'" fill="'+GN+'"/>';x+=cmW;
if(blW>0)bs+='<rect x="'+px(x)+'" y="'+y+'" width="'+px(blW)+'" height="'+bH+'" fill="'+GY+'"/>';
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-16;
bs+='<rect x="'+LW+'" y="'+ly+'" width="10" height="10" fill="'+OX+'"/><text x="'+(LW+14)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Code</text>';
bs+='<rect x="'+(LW+56)+'" y="'+ly+'" width="10" height="10" fill="'+GN+'"/><text x="'+(LW+70)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Comments</text>';
bs+='<rect x="'+(LW+158)+'" y="'+ly+'" width="10" height="10" fill="'+GY+'"/><text x="'+(LW+172)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Blanks</text>';
bs+='</svg>';
var lbl='font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;text-align:center;';
el.innerHTML='<div style="overflow-x:auto;text-align:center;padding:6px 0;">'+
'<table style="display:inline-table;border-collapse:separate;border-spacing:56px 0;margin:0 auto;">'+
'<tr>'+
'<td style="vertical-align:top;padding:0;"><p style="'+lbl+'">Code Lines by Language</p>'+ds+'</td>'+
'<td style="vertical-align:top;padding:0;"><p style="'+lbl+'">Line Mix per Language</p>'+bs+'</td>'+
'</tr>'+
'</table>'+
'</div>';
})();
(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/{{ run_id }}/pdf';
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>
<footer class="site-footer">
oxide-sloc v{{ version }} — local source line analysis workbench ·
Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
· <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
· <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
</footer>
</body>
</html>
"##,
ext = "html"
)]
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>,
language_rows: Vec<LanguageSummaryRow>,
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,
pdf_generating: bool,
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 | 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:#b85d33; --nav-2:#7a371b; --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;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;margin-top:2px;line-height:1.2;}
.nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
.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;}
.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">Source Line Analysis</div>
</div>
</a>
<div class="nav-right">
<a href="/view-reports" class="nav-pill">View Reports</a>
<a href="/scan" class="nav-pill">New Scan</a>
<button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
<svg class="icon-moon" viewBox="0 0 24 24"><path d="M20 15.5A8.5 8.5 0 1 1 12.5 4 6.7 6.7 0 0 0 20 15.5Z"></path></svg>
<svg class="icon-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4.2"></circle><path d="M12 2.5v2.2M12 19.3v2.2M21.5 12h-2.2M4.7 12H2.5M18.9 5.1l-1.6 1.6M6.7 17.3l-1.6 1.6M18.9 18.9l-1.6-1.6M6.7 6.7 5.1 5.1"></path></svg>
</button>
</div>
</div>
</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/' + encodeURIComponent(data.run_id) + '/result';
} 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 source line analysis workbench ·
Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
· <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
· <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
</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>
</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:#b85d33; --nav-2:#7a371b; --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;} .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;margin-top:2px;line-height:1.2;}
.nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
.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;}
.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;}.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 analysis workbench</div>
</div>
</a>
<div class="nav-right">
<a class="nav-pill" href="/">Home</a>
<a class="nav-pill" href="/view-reports">View Reports</a>
<a class="nav-pill" href="/compare-scans">Compare Scans</a>
<div class="nav-dropdown">
<button class="nav-dropdown-btn" type="button">Git Tools <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></button>
<div class="nav-dropdown-menu">
<a href="/git-browser"><svg viewBox="0 0 24 24"><polyline points="16 18 22 12 16 6"></polyline><polyline points="8 6 2 12 8 18"></polyline></svg>Git Browser</a>
<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>Webhooks</a>
</div>
</div>
<div class="server-status-wrap">
<div class="nav-pill server-online-pill"><span class="status-dot"></span>Server 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="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>Analysis failed</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>
{% endif %}
<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 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>
</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,
}
// ── 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:#b85d33; --nav-2:#7a371b; --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;} .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;margin-top:2px;line-height:1.2;}
.nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
.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;}
.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;}
.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);}
.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;}
.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;}
.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;}.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;}
.vr-toolbar{display:grid;grid-template-columns:1fr auto;gap:6px 20px;margin-bottom:14px;align-items:center;}
.vr-filters{grid-column:1;grid-row:2;display:flex;align-items:center;gap:10px;flex-wrap:wrap;}
.vr-hint{grid-column:2;grid-row:1;text-align:right;margin:0;font-size:13px;color:var(--muted);white-space:nowrap;}
.vr-browse{grid-column:2;grid-row:2;justify-self:end;white-space:nowrap;}
.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>
<a class="nav-pill" href="/view-reports">View Reports</a>
<a class="nav-pill" href="/compare-scans">Compare Scans</a>
<div class="nav-dropdown">
<button class="nav-dropdown-btn" type="button">Git Tools <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></button>
<div class="nav-dropdown-menu">
<a href="/git-browser"><svg viewBox="0 0 24 24"><polyline points="16 18 22 12 16 6"></polyline><polyline points="8 6 2 12 8 18"></polyline></svg>Git Browser</a>
<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>Webhooks</a>
</div>
</div>
<div class="server-status-wrap">
<div class="nav-pill server-online-pill"><span class="status-dot"></span>Server 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="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 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 %}
{% 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>
<a class="btn-back" href="/">
<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>
Home
</a>
</div>
</div>
<div class="vr-toolbar">
<div class="vr-filters">
{% if !entries.is_empty() %}
<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>
{% endif %}
</div>
<p class="vr-hint">Have reports saved on disk? Select a folder to load them into the list.</p>
<button type="button" class="btn vr-browse" id="browse-report-btn">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.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>
Browse for Reports…
</button>
</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 use the browse button above to link an existing report.
</div>
{% else %}
<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/{{ entry.run_id }}/html">
<td>{{ entry.timestamp }}</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">
<a class="btn primary rpt-btn" href="/runs/{{ entry.run_id }}/html" target="_blank" rel="noopener" title="View HTML report">View</a>
{% if entry.has_pdf %}<a class="btn primary rpt-btn" href="/runs/{{ entry.run_id }}/pdf" 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 source line analysis workbench ·
Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
· <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
· <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
</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];
var ce = document.getElementById('agg-code'); if (ce) ce.textContent = Number(first.dataset.code).toLocaleString();
var fe = document.getElementById('agg-files'); if (fe) fe.textContent = first.dataset.files;
var se = document.getElementById('agg-skipped'); if (se) se.textContent = 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('browse-report-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 = '/locate-reports-dir';
var input = document.createElement('input');
input.type = 'hidden';
input.name = 'folder_path';
input.value = data.selected_path;
form.appendChild(input);
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>
</body>
</html>
"##,
ext = "html"
)]
struct HistoryTemplate {
version: &'static str,
entries: Vec<HistoryEntryRow>,
total_scans: usize,
linked_count: usize,
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:#b85d33; --nav-2:#7a371b; --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;} .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;margin-top:2px;line-height:1.2;}
.nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
.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;}
.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;}
.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(--accent-2);}
.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(--accent-2);}
.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(111,155,255,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;}
.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;}
#compare-table td:nth-child(3){white-space:normal;word-break:break-word;overflow:visible;}
.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;}
.filter-row{display:flex;align-items:center;gap:10px;margin-bottom:14px;flex-wrap:wrap;}
.filter-row>*{height:30px;box-sizing:border-box;}
.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(--accent-2);border-color:var(--accent-2);color:#fff;}
.pg-btn:disabled{opacity:.35;cursor:default;}
.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);}
.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;}
.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;}
@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;}.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>
<a class="nav-pill" href="/view-reports">View Reports</a>
<a class="nav-pill" href="/compare-scans">Compare Scans</a>
<div class="nav-dropdown">
<button class="nav-dropdown-btn" type="button">Git Tools <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></button>
<div class="nav-dropdown-menu">
<a href="/git-browser"><svg viewBox="0 0 24 24"><polyline points="16 18 22 12 16 6"></polyline><polyline points="8 6 2 12 8 18"></polyline></svg>Git Browser</a>
<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>Webhooks</a>
</div>
</div>
<div class="server-status-wrap">
<div class="nav-pill server-online-pill"><span class="status-dot"></span>Server 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="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 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;align-items:center;gap:8px;flex-wrap:wrap;">
<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>
<a class="btn-back" href="/">
<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>
Home
</a>
</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>.
</div>
{% else %}
<div class="instruction-bar">
<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 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>
<div class="table-wrap">
<table id="compare-table">
<colgroup>
<col style="width:3%">
<col style="width:12%">
<col style="width:13%">
<col style="width:9%">
<col style="width:6%">
<col style="width:9%">
<col style="width:8%">
<col style="width:6%">
<col style="width:8%">
<col style="width:14%">
<col style="width:12%">
</colgroup>
<thead>
<tr id="compare-thead">
<th style="text-align:center;padding-left:4px;padding-right:4px;"><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 style="text-align:center;padding-left:4px;padding-right:4px;"><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
<td>{{ entry.timestamp }}</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 source line analysis workbench ·
Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
· <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
· <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
</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('.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; }
});
var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
if (latestRow) {
var ce = document.getElementById('agg-code'); if (ce) ce.textContent = Number(latestRow.dataset.code).toLocaleString();
var fe = document.getElementById('agg-files'); if (fe) fe.textContent = 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);
});
});
// ── 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');
if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
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);
}
})();
// ── 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>
</body>
</html>
"##,
ext = "html"
)]
struct CompareSelectTemplate {
version: &'static str,
entries: Vec<HistoryEntryRow>,
total_scans: usize,
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:#b85d33; --nav-2:#7a371b;
--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:wrap;}
.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;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;margin-top:2px;line-height:1.2;}
.nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:wrap;}
.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;}
.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;}
.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;}
.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:fixed;}
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;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.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;}.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;}
</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>
<a class="nav-pill" href="/view-reports">View Reports</a>
<a class="nav-pill" href="/compare-scans">Compare Scans</a>
<div class="nav-dropdown">
<button class="nav-dropdown-btn" type="button">Git Tools <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></button>
<div class="nav-dropdown-menu">
<a href="/git-browser"><svg viewBox="0 0 24 24"><polyline points="16 18 22 12 16 6"></polyline><polyline points="8 6 2 12 8 18"></polyline></svg>Git Browser</a>
<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>Webhooks</a>
</div>
</div>
<div class="server-status-wrap">
<div class="nav-pill server-online-pill"><span class="status-dot"></span>Server 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="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 style="margin:0 0 6px;">Scan Delta</h1>
<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/{{ baseline_run_id }}/html" target="_blank">{{ baseline_git_commit }}</a>
{% else %}
<a class="meta-card-commit" href="/runs/{{ baseline_run_id }}/html" 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">{{ 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/{{ current_run_id }}/html" target="_blank">{{ current_git_commit }}</a>
{% else %}
<a class="meta-card-commit" href="/runs/{{ current_run_id }}/html" 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">{{ 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">
<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 style="width:55%">
<col style="width:7%">
<col style="width:7%">
<col style="width:12%">
<col style="width:6%">
<col style="width:6%">
<col style="width:7%">
</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>
<footer class="site-footer">
oxide-sloc v{{ version }} — local source line analysis workbench ·
Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
· <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
· <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
</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){return Number(n).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},{l:'Files Analyzed',b:sd.bf,c:sd.cf},{l:'Comments',b:sd.bcm,c:sd.ccm}];
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="'+GY+'" 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="#666">'+fmt(m.b)+'</text>';
c1+='<rect class="cb" x="'+c1x1+'" y="'+px(c1mt+c1ph-bh1)+'" width="'+c1bw+'" height="'+px(bh1)+'" fill="'+OX+'" 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="'+OX+'">'+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="'+OX+'">After</text>';
});
c1+='</svg>';
// ── Chart 2: Delta by Metric ─────────────────────────────────────────
var mets=[{l:'Code Lines',v:sd.cc-sd.bc},{l:'Files Analyzed',v:sd.cf-sd.bf},{l:'Comment Lines',v:sd.ccm-sd.bcm}];
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" fill="#444">'+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:#AAAAAA"><\/span>Baseline<\/span>'+
'<span><span class="dot" style="background:#C45C10"><\/span>Current<\/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());};
</script>
</body>
</html>
"##,
ext = "html"
)]
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,
current_timestamp: String,
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:#b85d33; --nav-2:#7a371b;
--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;}
.page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;}
.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>
<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>
</body>
</html>
"##,
ext = "html"
)]
struct LoginTemplate {
csp_nonce: String,
has_error: bool,
next_url: String,
lockout_threshold: u32,
}