use std::path::PathBuf;
use anyhow::Result;
use clap::Parser;
use nornir::viz::UrdrThreadsApp;
#[derive(Parser)]
#[command(name = "urdr-threads")]
#[command(about = "Time-travel visualizer for the Urðr warehouse", long_about = None)]
struct Cli {
#[arg(long)]
warehouse: Option<PathBuf>,
#[arg(long, default_value = "")]
workspace: String,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long)]
theme: Option<String>,
#[arg(long)]
screenshot: Option<String>,
}
fn workspace_from_path(p: &std::path::Path) -> Option<String> {
p.components()
.filter_map(|c| c.as_os_str().to_str())
.find_map(|s| s.strip_prefix("workspace_").map(str::to_string))
.filter(|s| !s.is_empty())
}
fn install_crash_hook() {
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let actionlog = std::env::var("NORNIR_VIZ_ACTIONLOG")
.unwrap_or_else(|_| "/tmp/nornir_viz_actions.log".to_string());
let crash_path = std::env::var("NORNIR_VIZ_CRASH")
.unwrap_or_else(|_| format!("{actionlog}.crash"));
let loc = info
.location()
.map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
.unwrap_or_else(|| "<unknown>".to_string());
let msg = info
.payload()
.downcast_ref::<&str>()
.map(|s| (*s).to_string())
.or_else(|| info.payload().downcast_ref::<String>().cloned())
.unwrap_or_else(|| "<non-string panic payload>".to_string());
let trail = std::fs::read_to_string(&actionlog)
.ok()
.map(|s| {
let lines: Vec<&str> = s.lines().collect();
lines[lines.len().saturating_sub(30)..].join("\n")
})
.unwrap_or_default();
let bt = std::backtrace::Backtrace::force_capture();
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&crash_path) {
use std::io::Write;
let _ = writeln!(
f,
"\n===== nornir-viz PANIC =====\nat {loc}\n{msg}\n--- last actions (what was clicked) ---\n{trail}\n--- backtrace ---\n{bt}\n============================",
);
}
eprintln!("nornir-viz PANIC at {loc}: {msg} (crash log → {crash_path})");
default_hook(info);
}));
}
fn main() -> Result<()> {
install_crash_hook();
let cli = Cli::parse();
let loaded = match cli.config.as_deref() {
Some(p) => nornir::config::load_explicit(p).ok(),
None => std::env::current_dir()
.ok()
.and_then(|cwd| nornir::config::discover(&cwd).ok()),
};
let (workspace_root, repos, config_workspace) = match loaded {
Some(loaded) => {
let config_ws = workspace_from_path(&loaded.config_path)
.or_else(|| workspace_from_path(std::path::Path::new(&loaded.nornir.storage.local_path)));
(
loaded.workspace_root,
loaded.nornir.repo.keys().cloned().collect(),
config_ws,
)
}
None => (PathBuf::new(), Vec::new(), None),
};
let native_options = eframe::NativeOptions {
viewport: eframe::egui::ViewportBuilder::default()
.with_inner_size([1200.0, 700.0])
.with_title(format!(
"Urðr Threads — nornir viz {}",
nornir::viz::client_build()
)),
..Default::default()
};
let server = std::env::var("NORNIR_SERVER").ok().filter(|s| !s.is_empty());
let workspace = std::env::var("NORNIR_WORKSPACE")
.ok()
.filter(|s| !s.is_empty())
.unwrap_or(cli.workspace);
let theme_env = std::env::var("NORNIR_VIZ_THEME").ok();
let initial_palette =
nornir::viz::facett_theme::Theme::resolve_initial(theme_env.as_deref(), cli.theme.as_deref());
if let Some(req) = theme_env.as_deref().filter(|s| !s.is_empty()).or(cli.theme.as_deref()) {
if nornir::viz::facett_theme::Theme::by_name(req).is_none() {
eprintln!(
"nornir-viz: unknown theme {req:?} — using `default`. Choices: {:?}",
nornir::viz::facett_theme::Theme::names(),
);
} else {
eprintln!("nornir-viz: initial palette → {}", initial_palette.name);
}
}
let screenshot_path = cli.screenshot.clone();
if let Some(ref path) = screenshot_path {
let cmd = nornir::viz::control::VizCommand {
screenshot: Some(nornir::viz::control::ScreenshotRequest {
out_path: Some(path.clone()),
}),
..Default::default()
};
if let Err(e) = nornir::viz::control::write_command(&cmd) {
eprintln!("nornir-viz: --screenshot: could not write control command: {e}");
} else {
eprintln!("nornir-viz: --screenshot → will write PNG to {path}");
}
}
eframe::run_native(
"Urðr Threads",
native_options,
Box::new(move |cc| {
cc.egui_ctx.set_visuals(eframe::egui::Visuals::dark());
let mut app = match server {
Some(endpoint) => {
let token = std::env::var("NORNIR_SERVER_TOKEN").unwrap_or_default();
UrdrThreadsApp::with_remote_preferring(
endpoint, token, workspace, config_workspace, workspace_root, repos,
)
}
None => {
let wh = cli.warehouse.unwrap_or_else(nornir::config::warehouse_default_root);
UrdrThreadsApp::with_repos(wh, workspace, workspace_root, repos)
}
};
app.set_palette(initial_palette);
Ok(Box::new(app))
}),
)
.map_err(|e| anyhow::anyhow!("eframe error: {e}"))?;
Ok(())
}