From e5772daa0708e037b310ff7809c705614512f9be Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Tue, 17 Feb 2026 16:30:12 -0500 Subject: [PATCH 1/6] feat: refine style on tag line a bit --- site/css/style.css | 3 +++ site/index.md | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/site/css/style.css b/site/css/style.css index 5bf9ab5..e1ce579 100644 --- a/site/css/style.css +++ b/site/css/style.css @@ -292,6 +292,9 @@ th { max-width: 42rem; line-height: 1.5; } +.hero .tagline strong { + color: var(--graphite); +} .hero-install { font-family: var(--mono); font-size: 0.9rem; diff --git a/site/index.md b/site/index.md index 29a5b09..6ec3b09 100644 --- a/site/index.md +++ b/site/index.md @@ -8,7 +8,7 @@ nav: home

Toolpath

- Know your tools. A tool-agnostic format for tracking artifact transformation provenance. + Know your tools. A tool-agnostic format for tracking artifact transformation provenance. Git blame, but for everything that happens to code — including the stuff git doesn't see.

From 37dcd60d722b5d16147ab9ec8b9d3b3c4b5d521b Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Tue, 17 Feb 2026 20:40:52 -0500 Subject: [PATCH 2/6] feat: path wasm command in web terminal --- .cargo/config.toml | 12 + .gitignore | 1 + Cargo.toml | 9 +- crates/toolpath-cli/Cargo.toml | 9 +- crates/toolpath-cli/src/cmd_derive.rs | 57 +- crates/toolpath-cli/src/cmd_list.rs | 91 +- crates/toolpath-git/Cargo.toml | 4 +- crates/toolpath-git/src/lib.rs | 1653 ++++++++++++------------- scripts/build-wasm.sh | 92 ++ scripts/site.sh | 49 +- site/_includes/base.njk | 6 + site/css/playground.css | 93 ++ site/eleventy.config.js | 14 + site/index.md | 11 + site/js/playground.js | 1287 +++++++++++++++++++ site/js/toolpath-core.js | 199 +++ site/js/visualizer.js | 135 +- site/pages/visualizer.njk | 1 + 18 files changed, 2691 insertions(+), 1032 deletions(-) create mode 100644 .cargo/config.toml create mode 100755 scripts/build-wasm.sh create mode 100644 site/css/playground.css create mode 100644 site/js/playground.js create mode 100644 site/js/toolpath-core.js diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..6f6aba9 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,12 @@ +[target.wasm32-unknown-emscripten] +rustflags = [ + "-C", "link-arg=-sINVOKE_RUN=0", + "-C", "link-arg=-sEXIT_RUNTIME=0", + "-C", "link-arg=-sMODULARIZE=1", + "-C", "link-arg=-sEXPORT_NAME=createPathModule", + "-C", "link-arg=-sEXPORTED_RUNTIME_METHODS=callMain,FS", + "-C", "link-arg=-sFORCE_FILESYSTEM=1", + "-C", "link-arg=-sALLOW_MEMORY_GROWTH=1", + "-C", "link-arg=-sENVIRONMENT=web", + "-C", "link-arg=-sSTACK_SIZE=1048576", +] diff --git a/.gitignore b/.gitignore index 9f3716f..60893b9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target /local +site/wasm/ diff --git a/Cargo.toml b/Cargo.toml index 250cf97..ee21c04 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ license = "Apache-2.0" [workspace.dependencies] toolpath = { version = "0.1.3", path = "crates/toolpath" } toolpath-git = { version = "0.1.2", path = "crates/toolpath-git" } -toolpath-claude = { version = "0.1.2", path = "crates/toolpath-claude" } +toolpath-claude = { version = "0.1.2", path = "crates/toolpath-claude", default-features = false } toolpath-dot = { version = "0.1.2", path = "crates/toolpath-dot" } serde = { version = "1.0", features = ["derive"] } @@ -29,3 +29,10 @@ tokio = { version = "1.40", features = ["full"] } notify = { version = "7", features = ["macos_kqueue"] } similar = "2" tempfile = "3.15" + +[profile.wasm] +inherits = "release" +opt-level = "z" +lto = true +codegen-units = 1 +strip = true diff --git a/crates/toolpath-cli/Cargo.toml b/crates/toolpath-cli/Cargo.toml index 94ad3ca..3275010 100644 --- a/crates/toolpath-cli/Cargo.toml +++ b/crates/toolpath-cli/Cargo.toml @@ -15,7 +15,6 @@ path = "src/main.rs" [dependencies] toolpath = { workspace = true } toolpath-git = { workspace = true } -toolpath-claude = { workspace = true } toolpath-dot = { workspace = true } clap = { workspace = true } anyhow = { workspace = true } @@ -24,7 +23,13 @@ serde_json = { workspace = true } similar = { workspace = true } chrono = { workspace = true } tempfile = { workspace = true } -git2 = { workspace = true } rand = "0.9" +[target.'cfg(not(target_os = "emscripten"))'.dependencies] +toolpath-claude = { workspace = true, features = ["watcher"] } +git2 = { workspace = true } + +[target.'cfg(target_os = "emscripten")'.dependencies] +toolpath-claude = { workspace = true } + [dev-dependencies] diff --git a/crates/toolpath-cli/src/cmd_derive.rs b/crates/toolpath-cli/src/cmd_derive.rs index 23e9ba9..9688863 100644 --- a/crates/toolpath-cli/src/cmd_derive.rs +++ b/crates/toolpath-cli/src/cmd_derive.rs @@ -1,4 +1,6 @@ -use anyhow::{Context, Result}; +#[cfg(not(target_os = "emscripten"))] +use anyhow::Context; +use anyhow::Result; use clap::Subcommand; use std::path::PathBuf; @@ -67,31 +69,42 @@ fn run_git( title: Option, pretty: bool, ) -> Result<()> { - let repo_path = if repo_path.is_absolute() { - repo_path - } else { - std::env::current_dir()?.join(&repo_path) - }; + #[cfg(target_os = "emscripten")] + { + let _ = (repo_path, branches, base, remote, title, pretty); + anyhow::bail!( + "'path derive git' requires a native environment with access to a git repository" + ); + } - let repo = git2::Repository::open(&repo_path) - .with_context(|| format!("Failed to open repository at {:?}", repo_path))?; + #[cfg(not(target_os = "emscripten"))] + { + let repo_path = if repo_path.is_absolute() { + repo_path + } else { + std::env::current_dir()?.join(&repo_path) + }; - let config = toolpath_git::DeriveConfig { - remote, - title, - base, - }; + let repo = git2::Repository::open(&repo_path) + .with_context(|| format!("Failed to open repository at {:?}", repo_path))?; + + let config = toolpath_git::DeriveConfig { + remote, + title, + base, + }; - let doc = toolpath_git::derive(&repo, &branches, &config)?; + let doc = toolpath_git::derive(&repo, &branches, &config)?; - let json = if pretty { - doc.to_json_pretty()? - } else { - doc.to_json()? - }; + let json = if pretty { + doc.to_json_pretty()? + } else { + doc.to_json()? + }; - println!("{}", json); - Ok(()) + println!("{}", json); + Ok(()) + } } fn run_claude(project: String, session: Option, all: bool, pretty: bool) -> Result<()> { @@ -143,7 +156,7 @@ fn run_claude_with_manager( Ok(()) } -#[cfg(test)] +#[cfg(all(test, not(target_os = "emscripten")))] mod tests { use super::*; diff --git a/crates/toolpath-cli/src/cmd_list.rs b/crates/toolpath-cli/src/cmd_list.rs index 893b024..97cbfe2 100644 --- a/crates/toolpath-cli/src/cmd_list.rs +++ b/crates/toolpath-cli/src/cmd_list.rs @@ -1,4 +1,6 @@ -use anyhow::{Context, Result}; +#[cfg(not(target_os = "emscripten"))] +use anyhow::Context; +use anyhow::Result; use clap::Subcommand; use std::path::PathBuf; @@ -30,49 +32,60 @@ pub fn run(source: ListSource, json: bool) -> Result<()> { } fn run_git(repo_path: PathBuf, remote: String, json: bool) -> Result<()> { - let repo_path = if repo_path.is_absolute() { - repo_path - } else { - std::env::current_dir()?.join(&repo_path) - }; - - let repo = git2::Repository::open(&repo_path) - .with_context(|| format!("Failed to open repository at {:?}", repo_path))?; - - let uri = toolpath_git::get_repo_uri(&repo, &remote)?; - let branches = toolpath_git::list_branches(&repo)?; + #[cfg(target_os = "emscripten")] + { + let _ = (repo_path, remote, json); + anyhow::bail!( + "'path list git' requires a native environment with access to a git repository" + ); + } - if json { - let items: Vec = branches - .iter() - .map(|b| { - serde_json::json!({ - "name": b.name, - "head": b.head, - "subject": b.subject, - "author": b.author, - "timestamp": b.timestamp, + #[cfg(not(target_os = "emscripten"))] + { + let repo_path = if repo_path.is_absolute() { + repo_path + } else { + std::env::current_dir()?.join(&repo_path) + }; + + let repo = git2::Repository::open(&repo_path) + .with_context(|| format!("Failed to open repository at {:?}", repo_path))?; + + let uri = toolpath_git::get_repo_uri(&repo, &remote)?; + let branches = toolpath_git::list_branches(&repo)?; + + if json { + let items: Vec = branches + .iter() + .map(|b| { + serde_json::json!({ + "name": b.name, + "head": b.head, + "subject": b.subject, + "author": b.author, + "timestamp": b.timestamp, + }) }) - }) - .collect(); - let output = serde_json::json!({ - "source": "git", - "uri": uri, - "branches": items, - }); - println!("{}", serde_json::to_string_pretty(&output)?); - } else { - println!("Repository: {}", uri); - println!(); - if branches.is_empty() { - println!(" (no local branches)"); + .collect(); + let output = serde_json::json!({ + "source": "git", + "uri": uri, + "branches": items, + }); + println!("{}", serde_json::to_string_pretty(&output)?); } else { - for b in &branches { - println!(" {} {} {}", b.head_short, b.name, truncate(&b.subject, 60)); + println!("Repository: {}", uri); + println!(); + if branches.is_empty() { + println!(" (no local branches)"); + } else { + for b in &branches { + println!(" {} {} {}", b.head_short, b.name, truncate(&b.subject, 60)); + } } } + Ok(()) } - Ok(()) } fn run_claude(project: Option, json: bool) -> Result<()> { @@ -167,7 +180,7 @@ fn truncate(s: &str, max: usize) -> String { } } -#[cfg(test)] +#[cfg(all(test, not(target_os = "emscripten")))] mod tests { use super::*; diff --git a/crates/toolpath-git/Cargo.toml b/crates/toolpath-git/Cargo.toml index 4dad749..f5a27d3 100644 --- a/crates/toolpath-git/Cargo.toml +++ b/crates/toolpath-git/Cargo.toml @@ -10,9 +10,11 @@ categories = ["development-tools"] [dependencies] toolpath = { workspace = true } -git2 = { workspace = true } chrono = { workspace = true } anyhow = { workspace = true } +[target.'cfg(not(target_os = "emscripten"))'.dependencies] +git2 = { workspace = true } + [dev-dependencies] tempfile = "3" diff --git a/crates/toolpath-git/src/lib.rs b/crates/toolpath-git/src/lib.rs index 42adae1..23d9784 100644 --- a/crates/toolpath-git/src/lib.rs +++ b/crates/toolpath-git/src/lib.rs @@ -1,16 +1,7 @@ #![doc = include_str!("../README.md")] -use anyhow::{Context, Result}; -use chrono::{DateTime, Utc}; -use git2::{Commit, DiffOptions, Oid, Repository}; -use std::collections::HashMap; -use toolpath::v1::{ - ActorDefinition, ArtifactChange, Base, Document, Graph, GraphIdentity, GraphMeta, Identity, - Path, PathIdentity, PathMeta, PathOrRef, Step, StepIdentity, StepMeta, VcsSource, -}; - // ============================================================================ -// Public configuration and types +// Public configuration and types (available on all targets) // ============================================================================ /// Configuration for deriving Toolpath documents from a git repository. @@ -52,184 +43,27 @@ impl BranchSpec { } } -// ============================================================================ -// Public API -// ============================================================================ - -/// Derive a Toolpath [`Document`] from the given repository and branch names. -/// -/// Branch strings are parsed as [`BranchSpec`]s (supporting `"name:start"` syntax). -/// A single branch produces a [`Document::Path`]; multiple branches produce a -/// [`Document::Graph`]. -pub fn derive(repo: &Repository, branches: &[String], config: &DeriveConfig) -> Result { - let branch_specs: Vec = branches.iter().map(|s| BranchSpec::parse(s)).collect(); - - if branch_specs.len() == 1 { - let path_doc = derive_path(repo, &branch_specs[0], config)?; - Ok(Document::Path(path_doc)) - } else { - let graph_doc = derive_graph(repo, &branch_specs, config)?; - Ok(Document::Graph(graph_doc)) - } -} - -/// Derive a Toolpath [`Path`] from a single branch specification. -pub fn derive_path(repo: &Repository, spec: &BranchSpec, config: &DeriveConfig) -> Result { - let repo_uri = get_repo_uri(repo, &config.remote)?; - - let branch_ref = repo - .find_branch(&spec.name, git2::BranchType::Local) - .with_context(|| format!("Branch '{}' not found", spec.name))?; - let branch_commit = branch_ref.get().peel_to_commit()?; - - // Determine base commit - let base_oid = if let Some(global_base) = &config.base { - // Global base overrides per-branch - let obj = repo - .revparse_single(global_base) - .with_context(|| format!("Failed to parse base ref '{}'", global_base))?; - obj.peel_to_commit()?.id() - } else if let Some(start) = &spec.start { - // Per-branch start commit - resolve relative to the branch - // e.g., "main:HEAD~5" means 5 commits before main's HEAD - let start_ref = if let Some(rest) = start.strip_prefix("HEAD") { - // Replace HEAD with the branch name for relative refs - format!("{}{}", spec.name, rest) - } else { - start.clone() - }; - let obj = repo.revparse_single(&start_ref).with_context(|| { - format!( - "Failed to parse start ref '{}' (resolved to '{}') for branch '{}'", - start, start_ref, spec.name - ) - })?; - obj.peel_to_commit()?.id() - } else { - // Default: find merge-base with default branch - find_base_for_branch(repo, &branch_commit)? - }; - - let base_commit = repo.find_commit(base_oid)?; - - // Collect commits from base to head - let commits = collect_commits(repo, base_oid, branch_commit.id())?; - - // Generate steps and collect actor definitions - let mut actors: HashMap = HashMap::new(); - let steps = generate_steps(repo, &commits, base_oid, &mut actors)?; - - // Build path document - let head_step_id = if steps.is_empty() { - format!("step-{}", short_oid(branch_commit.id())) - } else { - steps.last().unwrap().step.id.clone() - }; - - Ok(Path { - path: PathIdentity { - id: format!("path-{}", spec.name.replace('/', "-")), - base: Some(Base { - uri: repo_uri, - ref_str: Some(base_commit.id().to_string()), - }), - head: head_step_id, - }, - steps, - meta: Some(PathMeta { - title: Some(format!("Branch: {}", spec.name)), - actors: if actors.is_empty() { - None - } else { - Some(actors) - }, - ..Default::default() - }), - }) -} - -/// Derive a Toolpath [`Graph`] from multiple branch specifications. -pub fn derive_graph( - repo: &Repository, - branch_specs: &[BranchSpec], - config: &DeriveConfig, -) -> Result { - // Find the default branch name - let default_branch = find_default_branch(repo); - - // If the default branch is included without an explicit start, compute the earliest - // merge-base among all other branches to use as its starting point - let default_branch_start = compute_default_branch_start(repo, branch_specs, &default_branch)?; - - // Generate paths for each branch with its own base - let mut paths = Vec::new(); - for spec in branch_specs { - // Check if this is the default branch and needs special handling - let effective_spec = if default_branch_start.is_some() - && spec.start.is_none() - && default_branch.as_ref() == Some(&spec.name) - { - BranchSpec { - name: spec.name.clone(), - start: default_branch_start.clone(), - } - } else { - spec.clone() - }; - let path_doc = derive_path(repo, &effective_spec, config)?; - paths.push(PathOrRef::Path(Box::new(path_doc))); - } - - // Create graph ID from branch names - let branch_names: Vec<&str> = branch_specs.iter().map(|s| s.name.as_str()).collect(); - let graph_id = if branch_names.len() <= 3 { - format!( - "graph-{}", - branch_names - .iter() - .map(|b| b.replace('/', "-")) - .collect::>() - .join("-") - ) - } else { - format!("graph-{}-branches", branch_names.len()) - }; - - let title = config - .title - .clone() - .unwrap_or_else(|| format!("Branches: {}", branch_names.join(", "))); - - Ok(Graph { - graph: GraphIdentity { id: graph_id }, - paths, - meta: Some(GraphMeta { - title: Some(title), - ..Default::default() - }), - }) +/// Summary information about a local branch. +#[derive(Debug, Clone)] +pub struct BranchInfo { + /// Branch name (e.g., "main", "feature/foo"). + pub name: String, + /// Short (8-char) hex of the tip commit. + pub head_short: String, + /// Full hex OID of the tip commit. + pub head: String, + /// First line of the tip commit message. + pub subject: String, + /// Author name of the tip commit. + pub author: String, + /// ISO 8601 timestamp of the tip commit. + pub timestamp: String, } // ============================================================================ -// Public utility functions +// Public utility functions (pure, available on all targets) // ============================================================================ -/// Get the repository URI from a remote, falling back to a file:// URI. -pub fn get_repo_uri(repo: &Repository, remote_name: &str) -> Result { - if let Ok(remote) = repo.find_remote(remote_name) - && let Some(url) = remote.url() - { - return Ok(normalize_git_url(url)); - } - - // Fall back to file path - if let Some(path) = repo.path().parent() { - return Ok(format!("file://{}", path.display())); - } - - Ok("file://unknown".to_string()) -} - /// Normalize a git remote URL to a canonical short form. /// /// Converts common hosting URLs to compact identifiers: @@ -312,328 +146,480 @@ pub fn slugify_author(name: &str, email: &str) -> String { } // ============================================================================ -// Listing / discovery +// git2-dependent code (native targets only) // ============================================================================ -/// Summary information about a local branch. -#[derive(Debug, Clone)] -pub struct BranchInfo { - /// Branch name (e.g., "main", "feature/foo"). - pub name: String, - /// Short (8-char) hex of the tip commit. - pub head_short: String, - /// Full hex OID of the tip commit. - pub head: String, - /// First line of the tip commit message. - pub subject: String, - /// Author name of the tip commit. - pub author: String, - /// ISO 8601 timestamp of the tip commit. - pub timestamp: String, -} +#[cfg(not(target_os = "emscripten"))] +mod native { + use anyhow::{Context, Result}; + use chrono::{DateTime, Utc}; + use git2::{Commit, DiffOptions, Oid, Repository}; + use std::collections::HashMap; + use toolpath::v1::{ + ActorDefinition, ArtifactChange, Base, Document, Graph, GraphIdentity, GraphMeta, Identity, + Path, PathIdentity, PathMeta, PathOrRef, Step, StepIdentity, StepMeta, VcsSource, + }; -/// List local branches with summary metadata. -pub fn list_branches(repo: &Repository) -> Result> { - let mut branches = Vec::new(); + use super::{BranchInfo, BranchSpec, DeriveConfig}; - for branch_result in repo.branches(Some(git2::BranchType::Local))? { - let (branch, _) = branch_result?; - let name = branch.name()?.unwrap_or("").to_string(); + /// Derive a Toolpath [`Document`] from the given repository and branch names. + /// + /// Branch strings are parsed as [`BranchSpec`]s (supporting `"name:start"` syntax). + /// A single branch produces a [`Document::Path`]; multiple branches produce a + /// [`Document::Graph`]. + pub fn derive( + repo: &Repository, + branches: &[String], + config: &DeriveConfig, + ) -> Result { + let branch_specs: Vec = branches.iter().map(|s| BranchSpec::parse(s)).collect(); + + if branch_specs.len() == 1 { + let path_doc = derive_path(repo, &branch_specs[0], config)?; + Ok(Document::Path(path_doc)) + } else { + let graph_doc = derive_graph(repo, &branch_specs, config)?; + Ok(Document::Graph(graph_doc)) + } + } - let commit = branch.get().peel_to_commit()?; + /// Derive a Toolpath [`Path`] from a single branch specification. + pub fn derive_path( + repo: &Repository, + spec: &BranchSpec, + config: &DeriveConfig, + ) -> Result { + let repo_uri = get_repo_uri(repo, &config.remote)?; + + let branch_ref = repo + .find_branch(&spec.name, git2::BranchType::Local) + .with_context(|| format!("Branch '{}' not found", spec.name))?; + let branch_commit = branch_ref.get().peel_to_commit()?; + + // Determine base commit + let base_oid = if let Some(global_base) = &config.base { + // Global base overrides per-branch + let obj = repo + .revparse_single(global_base) + .with_context(|| format!("Failed to parse base ref '{}'", global_base))?; + obj.peel_to_commit()?.id() + } else if let Some(start) = &spec.start { + // Per-branch start commit - resolve relative to the branch + // e.g., "main:HEAD~5" means 5 commits before main's HEAD + let start_ref = if let Some(rest) = start.strip_prefix("HEAD") { + // Replace HEAD with the branch name for relative refs + format!("{}{}", spec.name, rest) + } else { + start.clone() + }; + let obj = repo.revparse_single(&start_ref).with_context(|| { + format!( + "Failed to parse start ref '{}' (resolved to '{}') for branch '{}'", + start, start_ref, spec.name + ) + })?; + obj.peel_to_commit()?.id() + } else { + // Default: find merge-base with default branch + find_base_for_branch(repo, &branch_commit)? + }; - let author = commit.author(); - let author_name = author.name().unwrap_or("unknown").to_string(); + let base_commit = repo.find_commit(base_oid)?; - let time = commit.time(); - let timestamp = DateTime::::from_timestamp(time.seconds(), 0) - .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()) - .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string()); + // Collect commits from base to head + let commits = collect_commits(repo, base_oid, branch_commit.id())?; - let subject = commit - .message() - .unwrap_or("") - .lines() - .next() - .unwrap_or("") - .to_string(); - - branches.push(BranchInfo { - name, - head_short: short_oid(commit.id()), - head: commit.id().to_string(), - subject, - author: author_name, - timestamp, - }); - } + // Generate steps and collect actor definitions + let mut actors: HashMap = HashMap::new(); + let steps = generate_steps(repo, &commits, base_oid, &mut actors)?; - branches.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(branches) -} + // Build path document + let head_step_id = if steps.is_empty() { + format!("step-{}", short_oid(branch_commit.id())) + } else { + steps.last().unwrap().step.id.clone() + }; -// ============================================================================ -// Private helpers -// ============================================================================ + Ok(Path { + path: PathIdentity { + id: format!("path-{}", spec.name.replace('/', "-")), + base: Some(Base { + uri: repo_uri, + ref_str: Some(base_commit.id().to_string()), + }), + head: head_step_id, + }, + steps, + meta: Some(PathMeta { + title: Some(format!("Branch: {}", spec.name)), + actors: if actors.is_empty() { + None + } else { + Some(actors) + }, + ..Default::default() + }), + }) + } -/// When the default branch is included in a multi-branch graph without an explicit start, -/// compute the earliest merge-base among all feature branches to use as main's start. -/// This ensures we see main's commits back to where the earliest feature diverged. -fn compute_default_branch_start( - repo: &Repository, - branch_specs: &[BranchSpec], - default_branch: &Option, -) -> Result> { - let default_name = match default_branch { - Some(name) => name, - None => return Ok(None), - }; + /// Derive a Toolpath [`Graph`] from multiple branch specifications. + pub fn derive_graph( + repo: &Repository, + branch_specs: &[BranchSpec], + config: &DeriveConfig, + ) -> Result { + // Find the default branch name + let default_branch = find_default_branch(repo); + + // If the default branch is included without an explicit start, compute the earliest + // merge-base among all other branches to use as its starting point + let default_branch_start = + compute_default_branch_start(repo, branch_specs, &default_branch)?; + + // Generate paths for each branch with its own base + let mut paths = Vec::new(); + for spec in branch_specs { + // Check if this is the default branch and needs special handling + let effective_spec = if default_branch_start.is_some() + && spec.start.is_none() + && default_branch.as_ref() == Some(&spec.name) + { + BranchSpec { + name: spec.name.clone(), + start: default_branch_start.clone(), + } + } else { + spec.clone() + }; + let path_doc = derive_path(repo, &effective_spec, config)?; + paths.push(PathOrRef::Path(Box::new(path_doc))); + } - // Check if the default branch is in the list and doesn't have an explicit start - let default_in_list = branch_specs - .iter() - .any(|s| &s.name == default_name && s.start.is_none()); - if !default_in_list { - return Ok(None); + // Create graph ID from branch names + let branch_names: Vec<&str> = branch_specs.iter().map(|s| s.name.as_str()).collect(); + let graph_id = if branch_names.len() <= 3 { + format!( + "graph-{}", + branch_names + .iter() + .map(|b| b.replace('/', "-")) + .collect::>() + .join("-") + ) + } else { + format!("graph-{}-branches", branch_names.len()) + }; + + let title = config + .title + .clone() + .unwrap_or_else(|| format!("Branches: {}", branch_names.join(", "))); + + Ok(Graph { + graph: GraphIdentity { id: graph_id }, + paths, + meta: Some(GraphMeta { + title: Some(title), + ..Default::default() + }), + }) } - // Get the default branch commit - let default_ref = repo.find_branch(default_name, git2::BranchType::Local)?; - let default_commit = default_ref.get().peel_to_commit()?; + /// Get the repository URI from a remote, falling back to a file:// URI. + pub fn get_repo_uri(repo: &Repository, remote_name: &str) -> Result { + if let Ok(remote) = repo.find_remote(remote_name) + && let Some(url) = remote.url() + { + return Ok(super::normalize_git_url(url)); + } + + // Fall back to file path + if let Some(path) = repo.path().parent() { + return Ok(format!("file://{}", path.display())); + } - // Find the earliest merge-base among all non-default branches - let mut earliest_base: Option = None; + Ok("file://unknown".to_string()) + } - for spec in branch_specs { - if &spec.name == default_name { - continue; + /// List local branches with summary metadata. + pub fn list_branches(repo: &Repository) -> Result> { + let mut branches = Vec::new(); + + for branch_result in repo.branches(Some(git2::BranchType::Local))? { + let (branch, _) = branch_result?; + let name = branch.name()?.unwrap_or("").to_string(); + + let commit = branch.get().peel_to_commit()?; + + let author = commit.author(); + let author_name = author.name().unwrap_or("unknown").to_string(); + + let time = commit.time(); + let timestamp = DateTime::::from_timestamp(time.seconds(), 0) + .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()) + .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string()); + + let subject = commit + .message() + .unwrap_or("") + .lines() + .next() + .unwrap_or("") + .to_string(); + + branches.push(BranchInfo { + name, + head_short: short_oid(commit.id()), + head: commit.id().to_string(), + subject, + author: author_name, + timestamp, + }); } - let branch_ref = match repo.find_branch(&spec.name, git2::BranchType::Local) { - Ok(r) => r, - Err(_) => continue, - }; - let branch_commit = match branch_ref.get().peel_to_commit() { - Ok(c) => c, - Err(_) => continue, + branches.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(branches) + } + + // ======================================================================== + // Private helpers + // ======================================================================== + + fn compute_default_branch_start( + repo: &Repository, + branch_specs: &[BranchSpec], + default_branch: &Option, + ) -> Result> { + let default_name = match default_branch { + Some(name) => name, + None => return Ok(None), }; - if let Ok(merge_base) = repo.merge_base(default_commit.id(), branch_commit.id()) { - // Check if this merge-base is earlier (ancestor of) current earliest - match earliest_base { - None => earliest_base = Some(merge_base), - Some(current) => { - // If merge_base is an ancestor of current, use merge_base - // (it's "earlier" in the commit history) - if repo.merge_base(merge_base, current).ok() == Some(merge_base) - && merge_base != current - { - earliest_base = Some(merge_base); + let default_in_list = branch_specs + .iter() + .any(|s| &s.name == default_name && s.start.is_none()); + if !default_in_list { + return Ok(None); + } + + let default_ref = repo.find_branch(default_name, git2::BranchType::Local)?; + let default_commit = default_ref.get().peel_to_commit()?; + + let mut earliest_base: Option = None; + + for spec in branch_specs { + if &spec.name == default_name { + continue; + } + + let branch_ref = match repo.find_branch(&spec.name, git2::BranchType::Local) { + Ok(r) => r, + Err(_) => continue, + }; + let branch_commit = match branch_ref.get().peel_to_commit() { + Ok(c) => c, + Err(_) => continue, + }; + + if let Ok(merge_base) = repo.merge_base(default_commit.id(), branch_commit.id()) { + match earliest_base { + None => earliest_base = Some(merge_base), + Some(current) => { + if repo.merge_base(merge_base, current).ok() == Some(merge_base) + && merge_base != current + { + earliest_base = Some(merge_base); + } } } } } - } - // Use the GRANDPARENT of the earliest merge-base so both the merge-base and its parent - // are included in main's steps. This avoids showing an orphan BASE node. - if let Some(base_oid) = earliest_base - && let Ok(base_commit) = repo.find_commit(base_oid) - && base_commit.parent_count() > 0 - && let Ok(parent) = base_commit.parent(0) - { - // Try to get grandparent - if parent.parent_count() > 0 - && let Ok(grandparent) = parent.parent(0) + if let Some(base_oid) = earliest_base + && let Ok(base_commit) = repo.find_commit(base_oid) + && base_commit.parent_count() > 0 + && let Ok(parent) = base_commit.parent(0) { - return Ok(Some(grandparent.id().to_string())); + if parent.parent_count() > 0 + && let Ok(grandparent) = parent.parent(0) + { + return Ok(Some(grandparent.id().to_string())); + } + return Ok(Some(parent.id().to_string())); } - // Fall back to parent if no grandparent - return Ok(Some(parent.id().to_string())); + + Ok(earliest_base.map(|oid| oid.to_string())) } - Ok(earliest_base.map(|oid| oid.to_string())) -} + fn find_base_for_branch(repo: &Repository, branch_commit: &Commit) -> Result { + if let Some(default_branch) = find_default_branch(repo) + && let Ok(default_ref) = repo.find_branch(&default_branch, git2::BranchType::Local) + && let Ok(default_commit) = default_ref.get().peel_to_commit() + && default_commit.id() != branch_commit.id() + && let Ok(merge_base) = repo.merge_base(default_commit.id(), branch_commit.id()) + && merge_base != branch_commit.id() + { + return Ok(merge_base); + } -fn find_base_for_branch(repo: &Repository, branch_commit: &Commit) -> Result { - // Try to find merge-base with default branch, but only if the branch - // being derived is *not* the default branch itself (merge-base of a - // branch with itself is its own tip, which yields zero commits). - if let Some(default_branch) = find_default_branch(repo) - && let Ok(default_ref) = repo.find_branch(&default_branch, git2::BranchType::Local) - && let Ok(default_commit) = default_ref.get().peel_to_commit() - && default_commit.id() != branch_commit.id() - && let Ok(merge_base) = repo.merge_base(default_commit.id(), branch_commit.id()) - && merge_base != branch_commit.id() - { - return Ok(merge_base); - } + let mut walker = repo.revwalk()?; + walker.push(branch_commit.id())?; + walker.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::REVERSE)?; - // Fall back to first commit in history (root of the branch) - let mut walker = repo.revwalk()?; - walker.push(branch_commit.id())?; - walker.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::REVERSE)?; + if let Some(Ok(oid)) = walker.next() { + return Ok(oid); + } - if let Some(Ok(oid)) = walker.next() { - return Ok(oid); + Ok(branch_commit.id()) } - Ok(branch_commit.id()) -} - -fn find_default_branch(repo: &Repository) -> Option { - // Try common default branch names - for name in &["main", "master", "trunk", "develop"] { - if repo.find_branch(name, git2::BranchType::Local).is_ok() { - return Some(name.to_string()); + fn find_default_branch(repo: &Repository) -> Option { + for name in &["main", "master", "trunk", "develop"] { + if repo.find_branch(name, git2::BranchType::Local).is_ok() { + return Some(name.to_string()); + } } + None } - None -} -fn collect_commits<'a>( - repo: &'a Repository, - base_oid: Oid, - head_oid: Oid, -) -> Result>> { - let mut walker = repo.revwalk()?; - walker.push(head_oid)?; - walker.hide(base_oid)?; - walker.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::REVERSE)?; - - let mut commits = Vec::new(); - for oid_result in walker { - let oid = oid_result?; - let commit = repo.find_commit(oid)?; - commits.push(commit); + fn collect_commits<'a>( + repo: &'a Repository, + base_oid: Oid, + head_oid: Oid, + ) -> Result>> { + let mut walker = repo.revwalk()?; + walker.push(head_oid)?; + walker.hide(base_oid)?; + walker.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::REVERSE)?; + + let mut commits = Vec::new(); + for oid_result in walker { + let oid = oid_result?; + let commit = repo.find_commit(oid)?; + commits.push(commit); + } + + Ok(commits) } - Ok(commits) -} + fn generate_steps( + repo: &Repository, + commits: &[Commit], + base_oid: Oid, + actors: &mut HashMap, + ) -> Result> { + let mut steps = Vec::new(); + + for commit in commits { + let step = commit_to_step(repo, commit, base_oid, actors)?; + steps.push(step); + } -fn generate_steps( - repo: &Repository, - commits: &[Commit], - base_oid: Oid, - actors: &mut HashMap, -) -> Result> { - let mut steps = Vec::new(); - - for commit in commits { - let step = commit_to_step(repo, commit, base_oid, actors)?; - steps.push(step); + Ok(steps) } - Ok(steps) -} + fn commit_to_step( + repo: &Repository, + commit: &Commit, + base_oid: Oid, + actors: &mut HashMap, + ) -> Result { + let step_id = format!("step-{}", short_oid(commit.id())); + + let parents: Vec = commit + .parent_ids() + .filter(|pid| *pid != base_oid) + .map(|pid| format!("step-{}", short_oid(pid))) + .collect(); -fn commit_to_step( - repo: &Repository, - commit: &Commit, - base_oid: Oid, - actors: &mut HashMap, -) -> Result { - let step_id = format!("step-{}", short_oid(commit.id())); - - // Filter parents to only include those that aren't the base commit - let parents: Vec = commit - .parent_ids() - .filter(|pid| *pid != base_oid) - .map(|pid| format!("step-{}", short_oid(pid))) - .collect(); - - // Get author info - let author = commit.author(); - let author_name = author.name().unwrap_or("unknown"); - let author_email = author.email().unwrap_or("unknown"); - let actor = format!("human:{}", slugify_author(author_name, author_email)); - - // Register actor definition - actors.entry(actor.clone()).or_insert_with(|| { - let mut identities = Vec::new(); - if author_email != "unknown" { - identities.push(Identity { - system: "email".to_string(), - id: author_email.to_string(), - }); - } - ActorDefinition { - name: Some(author_name.to_string()), - identities, - ..Default::default() - } - }); + let author = commit.author(); + let author_name = author.name().unwrap_or("unknown"); + let author_email = author.email().unwrap_or("unknown"); + let actor = format!("human:{}", super::slugify_author(author_name, author_email)); + + actors.entry(actor.clone()).or_insert_with(|| { + let mut identities = Vec::new(); + if author_email != "unknown" { + identities.push(Identity { + system: "email".to_string(), + id: author_email.to_string(), + }); + } + ActorDefinition { + name: Some(author_name.to_string()), + identities, + ..Default::default() + } + }); - // Get timestamp - let time = commit.time(); - let timestamp = DateTime::::from_timestamp(time.seconds(), 0) - .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()) - .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string()); + let time = commit.time(); + let timestamp = DateTime::::from_timestamp(time.seconds(), 0) + .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()) + .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string()); - // Generate diff - let change = generate_diff(repo, commit)?; + let change = generate_diff(repo, commit)?; - // Get commit message as intent - let message = commit.message().unwrap_or("").trim(); - let intent = if message.is_empty() { - None - } else { - // Use first line of commit message - Some(message.lines().next().unwrap_or(message).to_string()) - }; + let message = commit.message().unwrap_or("").trim(); + let intent = if message.is_empty() { + None + } else { + Some(message.lines().next().unwrap_or(message).to_string()) + }; - // VCS source reference - let source = VcsSource { - vcs_type: "git".to_string(), - revision: commit.id().to_string(), - change_id: None, - }; + let source = VcsSource { + vcs_type: "git".to_string(), + revision: commit.id().to_string(), + change_id: None, + }; - Ok(Step { - step: StepIdentity { - id: step_id, - parents, - actor, - timestamp, - }, - change, - meta: Some(StepMeta { - intent, - source: Some(source), - ..Default::default() - }), - }) -} + Ok(Step { + step: StepIdentity { + id: step_id, + parents, + actor, + timestamp, + }, + change, + meta: Some(StepMeta { + intent, + source: Some(source), + ..Default::default() + }), + }) + } -fn generate_diff(repo: &Repository, commit: &Commit) -> Result> { - let tree = commit.tree()?; + fn generate_diff( + repo: &Repository, + commit: &Commit, + ) -> Result> { + let tree = commit.tree()?; - let parent_tree = if commit.parent_count() > 0 { - Some(commit.parent(0)?.tree()?) - } else { - None - }; + let parent_tree = if commit.parent_count() > 0 { + Some(commit.parent(0)?.tree()?) + } else { + None + }; - let mut diff_opts = DiffOptions::new(); - diff_opts.context_lines(3); + let mut diff_opts = DiffOptions::new(); + diff_opts.context_lines(3); - let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), Some(&mut diff_opts))?; + let diff = + repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), Some(&mut diff_opts))?; - let mut changes: HashMap = HashMap::new(); - let mut current_file: Option = None; - let mut current_diff = String::new(); + let mut changes: HashMap = HashMap::new(); + let mut current_file: Option = None; + let mut current_diff = String::new(); - diff.print(git2::DiffFormat::Patch, |delta, _hunk, line| { - let file_path = delta - .new_file() - .path() - .or_else(|| delta.old_file().path()) - .map(|p| p.to_string_lossy().to_string()); + diff.print(git2::DiffFormat::Patch, |delta, _hunk, line| { + let file_path = delta + .new_file() + .path() + .or_else(|| delta.old_file().path()) + .map(|p| p.to_string_lossy().to_string()); - if let Some(path) = file_path { - // Check if we're starting a new file - if current_file.as_ref() != Some(&path) { - // Save previous file's diff + if let Some(path) = file_path + && current_file.as_ref() != Some(&path) + { if let Some(prev_file) = current_file.take() && !current_diff.is_empty() { @@ -642,56 +628,394 @@ fn generate_diff(repo: &Repository, commit: &Commit) -> Result "+", - '-' => "-", - ' ' => " ", - '>' => ">", - '<' => "<", - 'F' => "", // File header - 'H' => "@", // Hunk header - we'll handle this specially - 'B' => "", - _ => "", - }; - if line.origin() == 'H' { - // Hunk header - if let Ok(content) = std::str::from_utf8(line.content()) { - current_diff.push_str("@@"); - current_diff.push_str(content.trim_start_matches('@')); + let prefix = match line.origin() { + '+' => "+", + '-' => "-", + ' ' => " ", + '>' => ">", + '<' => "<", + 'F' => "", + 'H' => "@", + 'B' => "", + _ => "", + }; + + if line.origin() == 'H' { + if let Ok(content) = std::str::from_utf8(line.content()) { + current_diff.push_str("@@"); + current_diff.push_str(content.trim_start_matches('@')); + } + } else if (!prefix.is_empty() || line.origin() == ' ') + && let Ok(content) = std::str::from_utf8(line.content()) + { + current_diff.push_str(prefix); + current_diff.push_str(content); } - } else if (!prefix.is_empty() || line.origin() == ' ') - && let Ok(content) = std::str::from_utf8(line.content()) + + true + })?; + + if let Some(file) = current_file + && !current_diff.is_empty() { - current_diff.push_str(prefix); - current_diff.push_str(content); + changes.insert(file, ArtifactChange::raw(¤t_diff)); } - true - })?; + Ok(changes) + } - // Don't forget the last file - if let Some(file) = current_file - && !current_diff.is_empty() - { - changes.insert(file, ArtifactChange::raw(¤t_diff)); + fn short_oid(oid: Oid) -> String { + safe_prefix(&oid.to_string(), 8) } - Ok(changes) -} + fn safe_prefix(s: &str, n: usize) -> String { + s.chars().take(n).collect() + } -fn short_oid(oid: Oid) -> String { - safe_prefix(&oid.to_string(), 8) -} + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn test_safe_prefix_ascii() { + assert_eq!(safe_prefix("abcdef12345", 8), "abcdef12"); + } + + #[test] + fn test_safe_prefix_short_string() { + assert_eq!(safe_prefix("abc", 8), "abc"); + } + + #[test] + fn test_safe_prefix_empty() { + assert_eq!(safe_prefix("", 8), ""); + } + + #[test] + fn test_safe_prefix_multibyte() { + assert_eq!(safe_prefix("café", 3), "caf"); + assert_eq!(safe_prefix("日本語テスト", 3), "日本語"); + } + + #[test] + fn test_short_oid() { + let oid = Oid::from_str("abcdef1234567890abcdef1234567890abcdef12").unwrap(); + assert_eq!(short_oid(oid), "abcdef12"); + } + + fn init_temp_repo() -> (tempfile::TempDir, Repository) { + let dir = tempfile::tempdir().unwrap(); + let repo = Repository::init(dir.path()).unwrap(); + + let mut config = repo.config().unwrap(); + config.set_str("user.name", "Test User").unwrap(); + config.set_str("user.email", "test@example.com").unwrap(); + + (dir, repo) + } + + fn create_commit( + repo: &Repository, + message: &str, + file_name: &str, + content: &str, + parent: Option<&git2::Commit>, + ) -> Oid { + let mut index = repo.index().unwrap(); + let file_path = repo.workdir().unwrap().join(file_name); + std::fs::write(&file_path, content).unwrap(); + index.add_path(std::path::Path::new(file_name)).unwrap(); + index.write().unwrap(); + let tree_id = index.write_tree().unwrap(); + let tree = repo.find_tree(tree_id).unwrap(); + let sig = repo.signature().unwrap(); + let parents: Vec<&git2::Commit> = parent.into_iter().collect(); + repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &parents) + .unwrap() + } + + #[test] + fn test_list_branches_on_repo() { + let (_dir, repo) = init_temp_repo(); + create_commit(&repo, "initial", "file.txt", "hello", None); + + let branches = list_branches(&repo).unwrap(); + assert!(!branches.is_empty()); + let names: Vec<&str> = branches.iter().map(|b| b.name.as_str()).collect(); + assert!( + names.contains(&"main") || names.contains(&"master"), + "Expected main or master in {:?}", + names + ); + } + + #[test] + fn test_list_branches_sorted() { + let (_dir, repo) = init_temp_repo(); + let oid = create_commit(&repo, "initial", "file.txt", "hello", None); + let commit = repo.find_commit(oid).unwrap(); + + repo.branch("b-beta", &commit, false).unwrap(); + repo.branch("a-alpha", &commit, false).unwrap(); + + let branches = list_branches(&repo).unwrap(); + let names: Vec<&str> = branches.iter().map(|b| b.name.as_str()).collect(); + let mut sorted = names.clone(); + sorted.sort(); + assert_eq!(names, sorted); + } + + #[test] + fn test_get_repo_uri_no_remote() { + let (_dir, repo) = init_temp_repo(); + let uri = get_repo_uri(&repo, "origin").unwrap(); + assert!( + uri.starts_with("file://"), + "Expected file:// URI, got {}", + uri + ); + } + + #[test] + fn test_derive_single_branch() { + let (_dir, repo) = init_temp_repo(); + let oid1 = create_commit(&repo, "first commit", "file.txt", "v1", None); + let commit1 = repo.find_commit(oid1).unwrap(); + create_commit(&repo, "second commit", "file.txt", "v2", Some(&commit1)); + + let config = DeriveConfig { + remote: "origin".to_string(), + title: None, + base: None, + }; + + let default = find_default_branch(&repo).unwrap_or("main".to_string()); + let result = derive(&repo, &[default], &config).unwrap(); + + match result { + Document::Path(path) => { + assert!(!path.steps.is_empty(), "Expected at least one step"); + assert!(path.path.base.is_some()); + } + _ => panic!("Expected Document::Path for single branch"), + } + } + + #[test] + fn test_derive_multiple_branches_produces_graph() { + let (_dir, repo) = init_temp_repo(); + let oid1 = create_commit(&repo, "initial", "file.txt", "v1", None); + let commit1 = repo.find_commit(oid1).unwrap(); + let _oid2 = create_commit(&repo, "on default", "file.txt", "v2", Some(&commit1)); + + let default_branch = find_default_branch(&repo).unwrap(); + + repo.branch("feature", &commit1, false).unwrap(); + repo.set_head("refs/heads/feature").unwrap(); + repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force())) + .unwrap(); + let commit1_again = repo.find_commit(oid1).unwrap(); + create_commit( + &repo, + "feature work", + "feature.txt", + "feat", + Some(&commit1_again), + ); + + repo.set_head(&format!("refs/heads/{}", default_branch)) + .unwrap(); + repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force())) + .unwrap(); + + let config = DeriveConfig { + remote: "origin".to_string(), + title: Some("Test Graph".to_string()), + base: None, + }; + + let result = derive(&repo, &[default_branch, "feature".to_string()], &config).unwrap(); + + match result { + Document::Graph(graph) => { + assert_eq!(graph.paths.len(), 2); + assert!(graph.meta.is_some()); + assert_eq!(graph.meta.unwrap().title.unwrap(), "Test Graph"); + } + _ => panic!("Expected Document::Graph for multiple branches"), + } + } -/// Return the first `n` characters of a string, safe for any UTF-8 content. -fn safe_prefix(s: &str, n: usize) -> String { - s.chars().take(n).collect() + #[test] + fn test_find_default_branch() { + let (_dir, repo) = init_temp_repo(); + create_commit(&repo, "initial", "file.txt", "hello", None); + + let default = find_default_branch(&repo); + assert!(default.is_some()); + let name = default.unwrap(); + assert!(name == "main" || name == "master"); + } + + #[test] + fn test_branch_info_fields() { + let (_dir, repo) = init_temp_repo(); + create_commit(&repo, "test subject line", "file.txt", "hello", None); + + let branches = list_branches(&repo).unwrap(); + let branch = &branches[0]; + + assert!(!branch.head.is_empty()); + assert_eq!(branch.head_short.len(), 8); + assert_eq!(branch.subject, "test subject line"); + assert_eq!(branch.author, "Test User"); + assert!(branch.timestamp.ends_with('Z')); + } + + #[test] + fn test_derive_with_global_base() { + let (_dir, repo) = init_temp_repo(); + let oid1 = create_commit(&repo, "first commit", "file.txt", "v1", None); + let commit1 = repo.find_commit(oid1).unwrap(); + let oid2 = create_commit(&repo, "second commit", "file.txt", "v2", Some(&commit1)); + let commit2 = repo.find_commit(oid2).unwrap(); + create_commit(&repo, "third commit", "file.txt", "v3", Some(&commit2)); + + let default = find_default_branch(&repo).unwrap(); + let config = DeriveConfig { + remote: "origin".to_string(), + title: None, + base: Some(oid1.to_string()), + }; + + let result = derive(&repo, &[default], &config).unwrap(); + match result { + Document::Path(path) => { + assert!(path.steps.len() >= 1); + } + _ => panic!("Expected Document::Path"), + } + } + + #[test] + fn test_derive_path_with_branch_start() { + let (_dir, repo) = init_temp_repo(); + let oid1 = create_commit(&repo, "first", "file.txt", "v1", None); + let commit1 = repo.find_commit(oid1).unwrap(); + let oid2 = create_commit(&repo, "second", "file.txt", "v2", Some(&commit1)); + let commit2 = repo.find_commit(oid2).unwrap(); + create_commit(&repo, "third", "file.txt", "v3", Some(&commit2)); + + let default = find_default_branch(&repo).unwrap(); + let spec = BranchSpec { + name: default, + start: Some(oid1.to_string()), + }; + let config = DeriveConfig { + remote: "origin".to_string(), + title: None, + base: None, + }; + + let path = derive_path(&repo, &spec, &config).unwrap(); + assert!(path.steps.len() >= 1); + } + + #[test] + fn test_generate_diff_initial_commit() { + let (_dir, repo) = init_temp_repo(); + let oid = create_commit(&repo, "initial", "file.txt", "hello world", None); + let commit = repo.find_commit(oid).unwrap(); + + let changes = generate_diff(&repo, &commit).unwrap(); + assert!(!changes.is_empty()); + assert!(changes.contains_key("file.txt")); + } + + #[test] + fn test_collect_commits_range() { + let (_dir, repo) = init_temp_repo(); + let oid1 = create_commit(&repo, "first", "file.txt", "v1", None); + let commit1 = repo.find_commit(oid1).unwrap(); + let oid2 = create_commit(&repo, "second", "file.txt", "v2", Some(&commit1)); + let commit2 = repo.find_commit(oid2).unwrap(); + let oid3 = create_commit(&repo, "third", "file.txt", "v3", Some(&commit2)); + + let commits = collect_commits(&repo, oid1, oid3).unwrap(); + assert_eq!(commits.len(), 2); + } + + #[test] + fn test_graph_id_many_branches() { + let (_dir, repo) = init_temp_repo(); + let oid1 = create_commit(&repo, "initial", "file.txt", "v1", None); + let commit1 = repo.find_commit(oid1).unwrap(); + + repo.branch("b1", &commit1, false).unwrap(); + repo.branch("b2", &commit1, false).unwrap(); + repo.branch("b3", &commit1, false).unwrap(); + repo.branch("b4", &commit1, false).unwrap(); + + let config = DeriveConfig { + remote: "origin".to_string(), + title: None, + base: Some(oid1.to_string()), + }; + + let result = derive( + &repo, + &[ + "b1".to_string(), + "b2".to_string(), + "b3".to_string(), + "b4".to_string(), + ], + &config, + ) + .unwrap(); + + match result { + Document::Graph(g) => { + assert!(g.graph.id.contains("4-branches")); + } + _ => panic!("Expected Graph"), + } + } + + #[test] + fn test_commit_to_step_creates_actor() { + let (_dir, repo) = init_temp_repo(); + let oid = create_commit(&repo, "a commit", "file.txt", "content", None); + let commit = repo.find_commit(oid).unwrap(); + + let mut actors = HashMap::new(); + let step = commit_to_step(&repo, &commit, Oid::zero(), &mut actors).unwrap(); + + assert!(step.step.actor.starts_with("human:")); + assert!(!actors.is_empty()); + let actor_def = actors.values().next().unwrap(); + assert_eq!(actor_def.name.as_deref(), Some("Test User")); + } + + #[test] + fn test_derive_config_fields() { + let config = DeriveConfig { + remote: "origin".to_string(), + title: Some("My Graph".to_string()), + base: None, + }; + assert_eq!(config.remote, "origin"); + assert_eq!(config.title.as_deref(), Some("My Graph")); + assert!(config.base.is_none()); + } + } } +// Re-export native-only functions at crate root for API compatibility +#[cfg(not(target_os = "emscripten"))] +pub use native::{derive, derive_graph, derive_path, get_repo_uri, list_branches}; + #[cfg(test)] mod tests { use super::*; @@ -768,7 +1092,6 @@ mod tests { #[test] fn test_slugify_empty_email_username() { - // email with no @ — the split returns the full string, same as email assert_eq!(slugify_author("Test User", "noreply"), "test-user"); } @@ -794,356 +1117,4 @@ mod tests { assert_eq!(spec.name, "main"); assert_eq!(spec.start.as_deref(), Some("abc1234")); } - - // ── safe_prefix / short_oid ──────────────────────────────────────── - - #[test] - fn test_safe_prefix_ascii() { - assert_eq!(safe_prefix("abcdef12345", 8), "abcdef12"); - } - - #[test] - fn test_safe_prefix_short_string() { - assert_eq!(safe_prefix("abc", 8), "abc"); - } - - #[test] - fn test_safe_prefix_empty() { - assert_eq!(safe_prefix("", 8), ""); - } - - #[test] - fn test_safe_prefix_multibyte() { - // Ensure we don't panic on multi-byte chars - assert_eq!(safe_prefix("café", 3), "caf"); - assert_eq!(safe_prefix("日本語テスト", 3), "日本語"); - } - - #[test] - fn test_short_oid() { - let oid = Oid::from_str("abcdef1234567890abcdef1234567890abcdef12").unwrap(); - assert_eq!(short_oid(oid), "abcdef12"); - } - - // ── DeriveConfig default ─────────────────────────────────────────── - - #[test] - fn test_derive_config_fields() { - let config = DeriveConfig { - remote: "origin".to_string(), - title: Some("My Graph".to_string()), - base: None, - }; - assert_eq!(config.remote, "origin"); - assert_eq!(config.title.as_deref(), Some("My Graph")); - assert!(config.base.is_none()); - } - - // ── Integration tests with temp git repo ─────────────────────────── - - fn init_temp_repo() -> (tempfile::TempDir, Repository) { - let dir = tempfile::tempdir().unwrap(); - let repo = Repository::init(dir.path()).unwrap(); - - // Configure author for commits - let mut config = repo.config().unwrap(); - config.set_str("user.name", "Test User").unwrap(); - config.set_str("user.email", "test@example.com").unwrap(); - - (dir, repo) - } - - fn create_commit( - repo: &Repository, - message: &str, - file_name: &str, - content: &str, - parent: Option<&git2::Commit>, - ) -> Oid { - let mut index = repo.index().unwrap(); - let file_path = repo.workdir().unwrap().join(file_name); - std::fs::write(&file_path, content).unwrap(); - index.add_path(std::path::Path::new(file_name)).unwrap(); - index.write().unwrap(); - let tree_id = index.write_tree().unwrap(); - let tree = repo.find_tree(tree_id).unwrap(); - let sig = repo.signature().unwrap(); - let parents: Vec<&git2::Commit> = parent.into_iter().collect(); - repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &parents) - .unwrap() - } - - #[test] - fn test_list_branches_on_repo() { - let (_dir, repo) = init_temp_repo(); - // Create initial commit so a branch exists - create_commit(&repo, "initial", "file.txt", "hello", None); - - let branches = list_branches(&repo).unwrap(); - assert!(!branches.is_empty()); - // Should contain "main" or "master" depending on git config - let names: Vec<&str> = branches.iter().map(|b| b.name.as_str()).collect(); - assert!( - names.contains(&"main") || names.contains(&"master"), - "Expected main or master in {:?}", - names - ); - } - - #[test] - fn test_list_branches_sorted() { - let (_dir, repo) = init_temp_repo(); - let oid = create_commit(&repo, "initial", "file.txt", "hello", None); - let commit = repo.find_commit(oid).unwrap(); - - // Create additional branches - repo.branch("b-beta", &commit, false).unwrap(); - repo.branch("a-alpha", &commit, false).unwrap(); - - let branches = list_branches(&repo).unwrap(); - let names: Vec<&str> = branches.iter().map(|b| b.name.as_str()).collect(); - // Should be sorted alphabetically - let mut sorted = names.clone(); - sorted.sort(); - assert_eq!(names, sorted); - } - - #[test] - fn test_get_repo_uri_no_remote() { - let (_dir, repo) = init_temp_repo(); - let uri = get_repo_uri(&repo, "origin").unwrap(); - assert!( - uri.starts_with("file://"), - "Expected file:// URI, got {}", - uri - ); - } - - #[test] - fn test_derive_single_branch() { - let (_dir, repo) = init_temp_repo(); - let oid1 = create_commit(&repo, "first commit", "file.txt", "v1", None); - let commit1 = repo.find_commit(oid1).unwrap(); - create_commit(&repo, "second commit", "file.txt", "v2", Some(&commit1)); - - let config = DeriveConfig { - remote: "origin".to_string(), - title: None, - base: None, - }; - - // Get the default branch name - let default = find_default_branch(&repo).unwrap_or("main".to_string()); - let result = derive(&repo, &[default], &config).unwrap(); - - match result { - Document::Path(path) => { - assert!(!path.steps.is_empty(), "Expected at least one step"); - assert!(path.path.base.is_some()); - } - _ => panic!("Expected Document::Path for single branch"), - } - } - - #[test] - fn test_derive_multiple_branches_produces_graph() { - let (_dir, repo) = init_temp_repo(); - let oid1 = create_commit(&repo, "initial", "file.txt", "v1", None); - let commit1 = repo.find_commit(oid1).unwrap(); - let _oid2 = create_commit(&repo, "on default", "file.txt", "v2", Some(&commit1)); - - let default_branch = find_default_branch(&repo).unwrap(); - - // Create a feature branch from commit1 - repo.branch("feature", &commit1, false).unwrap(); - repo.set_head("refs/heads/feature").unwrap(); - repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force())) - .unwrap(); - let commit1_again = repo.find_commit(oid1).unwrap(); - create_commit( - &repo, - "feature work", - "feature.txt", - "feat", - Some(&commit1_again), - ); - - // Go back to default branch - repo.set_head(&format!("refs/heads/{}", default_branch)) - .unwrap(); - repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force())) - .unwrap(); - - let config = DeriveConfig { - remote: "origin".to_string(), - title: Some("Test Graph".to_string()), - base: None, - }; - - let result = derive(&repo, &[default_branch, "feature".to_string()], &config).unwrap(); - - match result { - Document::Graph(graph) => { - assert_eq!(graph.paths.len(), 2); - assert!(graph.meta.is_some()); - assert_eq!(graph.meta.unwrap().title.unwrap(), "Test Graph"); - } - _ => panic!("Expected Document::Graph for multiple branches"), - } - } - - #[test] - fn test_find_default_branch() { - let (_dir, repo) = init_temp_repo(); - create_commit(&repo, "initial", "file.txt", "hello", None); - - let default = find_default_branch(&repo); - assert!(default.is_some()); - // git init creates "main" or "master" depending on git config - let name = default.unwrap(); - assert!(name == "main" || name == "master"); - } - - #[test] - fn test_branch_info_fields() { - let (_dir, repo) = init_temp_repo(); - create_commit(&repo, "test subject line", "file.txt", "hello", None); - - let branches = list_branches(&repo).unwrap(); - let branch = &branches[0]; - - assert!(!branch.head.is_empty()); - assert_eq!(branch.head_short.len(), 8); - assert_eq!(branch.subject, "test subject line"); - assert_eq!(branch.author, "Test User"); - assert!(branch.timestamp.ends_with('Z')); - } - - #[test] - fn test_derive_with_global_base() { - let (_dir, repo) = init_temp_repo(); - let oid1 = create_commit(&repo, "first commit", "file.txt", "v1", None); - let commit1 = repo.find_commit(oid1).unwrap(); - let oid2 = create_commit(&repo, "second commit", "file.txt", "v2", Some(&commit1)); - let commit2 = repo.find_commit(oid2).unwrap(); - create_commit(&repo, "third commit", "file.txt", "v3", Some(&commit2)); - - let default = find_default_branch(&repo).unwrap(); - let config = DeriveConfig { - remote: "origin".to_string(), - title: None, - base: Some(oid1.to_string()), - }; - - let result = derive(&repo, &[default], &config).unwrap(); - match result { - Document::Path(path) => { - // Should only include commits after oid1 - assert!(path.steps.len() >= 1); - } - _ => panic!("Expected Document::Path"), - } - } - - #[test] - fn test_derive_path_with_branch_start() { - let (_dir, repo) = init_temp_repo(); - let oid1 = create_commit(&repo, "first", "file.txt", "v1", None); - let commit1 = repo.find_commit(oid1).unwrap(); - let oid2 = create_commit(&repo, "second", "file.txt", "v2", Some(&commit1)); - let commit2 = repo.find_commit(oid2).unwrap(); - create_commit(&repo, "third", "file.txt", "v3", Some(&commit2)); - - let default = find_default_branch(&repo).unwrap(); - let spec = BranchSpec { - name: default, - start: Some(oid1.to_string()), - }; - let config = DeriveConfig { - remote: "origin".to_string(), - title: None, - base: None, - }; - - let path = derive_path(&repo, &spec, &config).unwrap(); - assert!(path.steps.len() >= 1); - } - - #[test] - fn test_generate_diff_initial_commit() { - let (_dir, repo) = init_temp_repo(); - let oid = create_commit(&repo, "initial", "file.txt", "hello world", None); - let commit = repo.find_commit(oid).unwrap(); - - let changes = generate_diff(&repo, &commit).unwrap(); - // Initial commit should have a diff for the new file - assert!(!changes.is_empty()); - assert!(changes.contains_key("file.txt")); - } - - #[test] - fn test_collect_commits_range() { - let (_dir, repo) = init_temp_repo(); - let oid1 = create_commit(&repo, "first", "file.txt", "v1", None); - let commit1 = repo.find_commit(oid1).unwrap(); - let oid2 = create_commit(&repo, "second", "file.txt", "v2", Some(&commit1)); - let commit2 = repo.find_commit(oid2).unwrap(); - let oid3 = create_commit(&repo, "third", "file.txt", "v3", Some(&commit2)); - - let commits = collect_commits(&repo, oid1, oid3).unwrap(); - assert_eq!(commits.len(), 2); // second and third, not first - } - - #[test] - fn test_graph_id_many_branches() { - let (_dir, repo) = init_temp_repo(); - let oid1 = create_commit(&repo, "initial", "file.txt", "v1", None); - let commit1 = repo.find_commit(oid1).unwrap(); - - // Create 4 branches - repo.branch("b1", &commit1, false).unwrap(); - repo.branch("b2", &commit1, false).unwrap(); - repo.branch("b3", &commit1, false).unwrap(); - repo.branch("b4", &commit1, false).unwrap(); - - let config = DeriveConfig { - remote: "origin".to_string(), - title: None, - base: Some(oid1.to_string()), - }; - - let result = derive( - &repo, - &[ - "b1".to_string(), - "b2".to_string(), - "b3".to_string(), - "b4".to_string(), - ], - &config, - ) - .unwrap(); - - match result { - Document::Graph(g) => { - assert!(g.graph.id.contains("4-branches")); - } - _ => panic!("Expected Graph"), - } - } - - #[test] - fn test_commit_to_step_creates_actor() { - let (_dir, repo) = init_temp_repo(); - let oid = create_commit(&repo, "a commit", "file.txt", "content", None); - let commit = repo.find_commit(oid).unwrap(); - - let mut actors = HashMap::new(); - let step = commit_to_step(&repo, &commit, Oid::zero(), &mut actors).unwrap(); - - assert!(step.step.actor.starts_with("human:")); - assert!(!actors.is_empty()); - let actor_def = actors.values().next().unwrap(); - assert_eq!(actor_def.name.as_deref(), Some("Test User")); - } } diff --git a/scripts/build-wasm.sh b/scripts/build-wasm.sh new file mode 100755 index 0000000..4301dfd --- /dev/null +++ b/scripts/build-wasm.sh @@ -0,0 +1,92 @@ +#!/bin/bash +set -e + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +WASM_JS="$ROOT/site/wasm/path.js" +WASM_BIN="$ROOT/site/wasm/path.wasm" +EMSDK_DIR="$ROOT/local/emsdk" + +# --- Parse flags -------------------------------------------------------------- +# --if-changed Skip build if outputs are newer than all Rust sources +# --dev Use dev profile (fast incremental builds, no LTO/strip) + +DEV=false +IF_CHANGED=false +for arg in "$@"; do + case "$arg" in + --dev) DEV=true ;; + --if-changed) IF_CHANGED=true ;; + esac +done + +if $DEV; then + PROFILE=dev + PROFILE_DIR=debug + SENTINEL="$ROOT/target/.wasm-dev-built" +else + PROFILE=wasm + PROFILE_DIR=wasm + SENTINEL="$ROOT/target/.wasm-built" +fi + +# --- Staleness check ---------------------------------------------------------- + +wasm_is_stale() { + [ ! -f "$WASM_JS" ] || [ ! -f "$WASM_BIN" ] || [ ! -f "$SENTINEL" ] && return 0 + + [ -n "$(find "$ROOT/crates" "$ROOT/Cargo.toml" "$ROOT/.cargo/config.toml" \ + \( -name '*.rs' -o -name 'Cargo.toml' \) \ + -newer "$SENTINEL" 2>/dev/null | head -1)" ] +} + +if $IF_CHANGED; then + if ! wasm_is_stale; then + exit 0 + fi + echo "wasm: Rust sources changed, rebuilding ($PROFILE)..." +fi + +# --- Ensure emsdk is available ------------------------------------------------ + +ensure_emsdk() { + # Already on PATH? + if command -v emcc &>/dev/null; then + return 0 + fi + + # Local install exists? Activate it. + if [ -f "$EMSDK_DIR/emsdk_env.sh" ]; then + echo "wasm: Activating local emsdk..." + source "$EMSDK_DIR/emsdk_env.sh" 2>/dev/null + return 0 + fi + + # Bootstrap: clone + install + activate + echo "wasm: Installing emsdk to target/emsdk (one-time)..." + git clone --depth 1 https://github.com/emscripten-core/emsdk.git "$EMSDK_DIR" + "$EMSDK_DIR/emsdk" install latest + "$EMSDK_DIR/emsdk" activate latest + source "$EMSDK_DIR/emsdk_env.sh" 2>/dev/null +} + +ensure_emsdk + +# --- Ensure rustup target ----------------------------------------------------- + +if ! rustup target list --installed 2>/dev/null | grep -q wasm32-unknown-emscripten; then + echo "wasm: Adding rustup target wasm32-unknown-emscripten..." + rustup target add wasm32-unknown-emscripten +fi + +# --- Build -------------------------------------------------------------------- + +cd "$ROOT" +cargo build --target wasm32-unknown-emscripten -p toolpath-cli --profile "$PROFILE" + +mkdir -p site/wasm +cp "target/wasm32-unknown-emscripten/$PROFILE_DIR/path.js" site/wasm/path.js +cp "target/wasm32-unknown-emscripten/$PROFILE_DIR/path.wasm" site/wasm/path.wasm +touch "$SENTINEL" + +echo "wasm: Built site/wasm/path.{js,wasm} ($PROFILE)" +ls -lh site/wasm/ diff --git a/scripts/site.sh b/scripts/site.sh index ea7e7e0..a82981a 100755 --- a/scripts/site.sh +++ b/scripts/site.sh @@ -1,11 +1,50 @@ #!/usr/bin/env bash set -euo pipefail -cd "$(dirname "$0")/../site" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +WASM_SCRIPT="$ROOT/scripts/build-wasm.sh" + +# --- Wasm watcher (polls every 2s for Rust source changes) ------------------- +wasm_watch() { + local flags=("$@") + while true; do + "$WASM_SCRIPT" --if-changed "${flags[@]}" 2>&1 | while IFS= read -r line; do echo "$line"; done + sleep 2 + done +} + +# --- Wasm build (best-effort: warn but don't block if emcc missing) ---------- +wasm_build_or_warn() { + if "$WASM_SCRIPT" "$@" 2>&1; then + return 0 + else + echo "" + echo " Note: wasm build failed — playground will show a fallback message." + echo " Install the Emscripten SDK and re-run to enable the wasm playground." + echo "" + return 0 + fi +} + +cd "$ROOT/site" case "${1:-dev}" in - dev) pnpm run dev ;; - build) pnpm run build ;; - install) pnpm install ;; - *) echo "Usage: scripts/site.sh [dev|build|install]" >&2; exit 1 ;; + dev) + wasm_build_or_warn --dev --if-changed + wasm_watch --dev & + WASM_PID=$! + trap 'kill $WASM_PID 2>/dev/null' EXIT + pnpm run dev + ;; + build) + wasm_build_or_warn + pnpm run build + ;; + install) + pnpm install + ;; + *) + echo "Usage: scripts/site.sh [dev|build|install]" >&2 + exit 1 + ;; esac diff --git a/site/_includes/base.njk b/site/_includes/base.njk index ea6dc7f..27886ef 100644 --- a/site/_includes/base.njk +++ b/site/_includes/base.njk @@ -9,6 +9,12 @@ + {% if nav == "home" %} + + + + + {% endif %}
+
+

Try it

+

+Explore Toolpath documents in your browser. Real path commands, real output. +

+ +
+
+ + + ## The problem When Claude writes code, `rustfmt` reformats it, and a human refines it, git blame attributes everything to the human's commit. The actual provenance is lost. Dead ends disappear. Tool contributions collapse into whoever typed `git commit`. diff --git a/site/js/playground.js b/site/js/playground.js new file mode 100644 index 0000000..a206ac5 --- /dev/null +++ b/site/js/playground.js @@ -0,0 +1,1287 @@ +// Toolpath Interactive Playground +// xterm.js terminal with wasm-compiled path CLI + +(function () { + "use strict"; + + // --- Virtual Filesystem --- + function VirtualFS(files) { + this.files = files || {}; + } + VirtualFS.prototype.list = function () { + return Object.keys(this.files).sort(); + }; + VirtualFS.prototype.get = function (name) { + return this.files[name] || null; + }; + VirtualFS.prototype.has = function (name) { + return name in this.files; + }; + VirtualFS.prototype.size = function (name) { + var content = this.files[name]; + if (!content) return 0; + // Approximate byte size + var bytes = 0; + for (var i = 0; i < content.length; i++) { + var c = content.charCodeAt(i); + bytes += c < 128 ? 1 : c < 2048 ? 2 : 3; + } + return bytes; + }; + VirtualFS.prototype.formatSize = function (bytes) { + if (bytes < 1024) return bytes + "B"; + return (bytes / 1024).toFixed(1) + "K"; + }; + + // --- Command Parser --- + function parseCommand(line) { + var tokens = []; + var current = ""; + var inQuote = false; + var quoteChar = ""; + for (var i = 0; i < line.length; i++) { + var ch = line[i]; + if (inQuote) { + if (ch === quoteChar) { + inQuote = false; + } else { + current += ch; + } + } else if (ch === '"' || ch === "'") { + inQuote = true; + quoteChar = ch; + } else if (ch === " " || ch === "\t") { + if (current) { + tokens.push(current); + current = ""; + } + } else { + current += ch; + } + } + if (current) tokens.push(current); + return tokens; + } + + // --- ANSI helpers --- + var ANSI = { + reset: "\x1b[0m", + bold: "\x1b[1m", + dim: "\x1b[2m", + copper: "\x1b[33m", // yellow slot = copper + red: "\x1b[31m", + green: "\x1b[32m", + pencil: "\x1b[90m", // bright black = pencil + white: "\x1b[37m", + cyan: "\x1b[36m", + magenta: "\x1b[35m", + }; + + function copperBold(s) { + return ANSI.copper + ANSI.bold + s + ANSI.reset; + } + function red(s) { + return ANSI.red + s + ANSI.reset; + } + function dim(s) { + return ANSI.dim + s + ANSI.reset; + } + function pencil(s) { + return ANSI.pencil + s + ANSI.reset; + } + + // --- Wasm layer --- + // Precompiled WebAssembly.Module (compiled once, instantiated per command) + var compiledWasm = null; + var wasmFiles = null; + var wasmReady = false; + var wasmError = null; + + function loadWasm(files) { + wasmFiles = files; + if (typeof createPathModule !== "function") { + wasmError = "Wasm module not loaded"; + return Promise.reject(new Error(wasmError)); + } + // Compile once — instantiation per command reuses this + return fetch("/wasm/path.wasm") + .then(function (resp) { + return WebAssembly.compileStreaming(resp); + }) + .then(function (mod) { + compiledWasm = mod; + wasmReady = true; + }) + .catch(function (err) { + wasmError = err.message || String(err); + throw err; + }); + } + + // Fresh Emscripten instance per call — exit() kills the instance, not us + function runPath(args) { + var stdout = ""; + var stderr = ""; + return createPathModule({ + noInitialRun: true, + instantiateWasm: function (imports, callback) { + WebAssembly.instantiate(compiledWasm, imports).then( + function (instance) { + callback(instance); + }, + ); + return {}; + }, + print: function (text) { + stdout += text + "\n"; + }, + printErr: function (text) { + stderr += text + "\n"; + }, + }).then(function (mod) { + for (var name in wasmFiles) { + mod.FS.writeFile("/" + name, wasmFiles[name]); + } + try { + mod.callMain(args); + } catch (e) { + // exit() throws to unwind — expected + } + return { stdout: stdout.trimEnd(), stderr: stderr.trimEnd() }; + }); + } + + // --- Shell builtins --- + function cmdHelp(fs) { + return [ + copperBold("TOOLPATH PLAYGROUND") + + " " + + dim("-- real path CLI compiled to WebAssembly"), + "", + dim("Shell builtins:"), + " " + copperBold("ls") + " List files", + " " + + copperBold("cat") + + " Display file contents", + " " + + copperBold("clear") + + " Clear terminal", + " " + + copperBold("help") + + " Show this message", + "", + dim("CLI commands (run the real binary):"), + " " + + copperBold("path validate") + + " --input Validate document", + " " + + copperBold("path query dead-ends") + + " --input Find abandoned branches", + " " + + copperBold("path query ancestors") + + " --input --step-id ", + " Walk parent chain", + " " + + copperBold("path query filter") + + " --input --actor ", + " Filter steps by actor", + " " + + copperBold("path render dot") + + " --input Generate DOT graph", + " " + + copperBold("path merge") + + " Merge documents", + " " + + copperBold("path haiku") + + " Random haiku", + " " + + copperBold("path --help") + + " Full CLI usage", + "", + dim("Files: " + fs.list().join(", ")), + ].join("\r\n"); + } + + function cmdLs(fs) { + var files = fs.list(); + var lines = []; + for (var i = 0; i < files.length; i++) { + var size = fs.formatSize(fs.size(files[i])); + var padded = + size + + Array(Math.max(0, 7 - size.length)) + .fill(" ") + .join(""); + lines.push(" " + pencil(padded) + " " + files[i]); + } + return lines.join("\r\n"); + } + + function cmdCat(fs, tokens) { + var file = tokens[1]; + if (!file) return red("Usage: cat "); + if (!fs.has(file)) return red("cat: " + file + ": No such file"); + return fs.get(file); + } + + // --- Command dispatcher --- + function dispatch(line, fs) { + var tokens = parseCommand(line.trim()); + if (tokens.length === 0) return null; + + var cmd = tokens[0]; + + if (cmd === "clear") return { clear: true }; + if (cmd === "help") return { output: cmdHelp(fs) }; + if (cmd === "ls") return { output: cmdLs(fs) }; + if (cmd === "cat") return { output: cmdCat(fs, tokens) }; + + if (cmd === "path") { + if (!wasmReady) { + if (wasmError) { + return { output: red("Wasm failed to load: " + wasmError) }; + } + return { output: dim("Loading CLI... try again in a moment.") }; + } + var args = tokens.slice(1); + // Returns a Promise — callers handle this + return runPath(args) + .then(function (result) { + var output = ""; + if (result.stdout) output += result.stdout; + if (result.stderr) { + if (output) output += "\r\n"; + output += result.stderr; + } + return { output: output || dim("(no output)") }; + }) + .catch(function (err) { + return { output: red("Error: " + (err.message || err)) }; + }); + } + + return { + output: red( + "Unknown command: " + + cmd + + ". Type " + + copperBold("help") + + " for usage.", + ), + }; + } + + // --- Word boundary helpers --- + function wordBoundaryRight(line, pos) { + var i = pos; + // skip current word chars + while (i < line.length && /\w/.test(line[i])) i++; + // skip non-word chars + while (i < line.length && !/\w/.test(line[i])) i++; + return i; + } + + function wordBoundaryLeft(line, pos) { + var i = pos; + // skip non-word chars behind cursor + while (i > 0 && !/\w/.test(line[i - 1])) i--; + // skip word chars + while (i > 0 && /\w/.test(line[i - 1])) i--; + return i; + } + + function wordEndRight(line, pos) { + var i = pos; + // skip non-word chars + while (i < line.length && !/\w/.test(line[i])) i++; + // skip word chars + while (i < line.length && /\w/.test(line[i])) i++; + return i; + } + + // --- WORD boundary helpers (whitespace-delimited, vim W/B/E) --- + function WORDBoundaryRight(line, pos) { + var i = pos; + // skip current non-whitespace + while (i < line.length && line[i] !== " " && line[i] !== "\t") i++; + // skip whitespace + while (i < line.length && (line[i] === " " || line[i] === "\t")) i++; + return i; + } + + function WORDBoundaryLeft(line, pos) { + var i = pos; + // skip whitespace behind cursor + while (i > 0 && (line[i - 1] === " " || line[i - 1] === "\t")) i--; + // skip non-whitespace + while (i > 0 && line[i - 1] !== " " && line[i - 1] !== "\t") i--; + return i; + } + + function WORDEndRight(line, pos) { + var i = pos; + // skip whitespace + while (i < line.length && (line[i] === " " || line[i] === "\t")) i++; + // skip non-whitespace + while (i < line.length && line[i] !== " " && line[i] !== "\t") i++; + return i; + } + + // --- Terminal Shell --- + function TermShell(container, fs) { + this.fs = fs; + this.history = []; + this.historyIndex = -1; + this.line = ""; + this.cursorPos = 0; + this.savedLine = ""; + + // Editing mode: "emacs" or "vi" + this.editMode = "vi"; + // Vi sub-mode: "insert" or "command" + this.viMode = "insert"; + // Pending vi operator: "d" or "c" or null + this.viPending = null; + // Kill ring for Ctrl+K/U/W/Y and vi yank + this.killRing = ""; + + this.term = new window.Terminal({ + theme: { + background: "#ece5db", + foreground: "#2d2a26", + cursor: "#b5652b", + cursorAccent: "#ece5db", + selectionBackground: "#b5652b30", + selectionForeground: "#2d2a26", + black: "#2d2a26", + red: "#c44030", + green: "#6e7d3a", + yellow: "#b5652b", + blue: "#8a8078", + magenta: "#9e5019", + cyan: "#8a8078", + white: "#f6f1eb", + brightBlack: "#8a8078", + brightRed: "#c44030", + brightGreen: "#6e7d3a", + brightYellow: "#b5652b", + brightBlue: "#8a8078", + brightMagenta: "#9e5019", + brightCyan: "#8a8078", + brightWhite: "#f6f1eb", + }, + fontFamily: "'IBM Plex Mono', monospace", + fontSize: 14, + rows: 20, + cursorStyle: "bar", + cursorBlink: true, + scrollback: 500, + convertEol: true, + allowProposedApi: true, + }); + + this.fitAddon = new window.FitAddon.FitAddon(); + this.term.loadAddon(this.fitAddon); + this.term.open(container); + this.fitAddon.fit(); + + // Build mode toggle + this.toggleEl = document.createElement("button"); + this.toggleEl.className = "playground-mode-toggle vi"; + this.toggleEl.textContent = "VI"; + this.toggleEl.title = "Switch editing mode"; + container.appendChild(this.toggleEl); + + var self = this; + this.toggleEl.addEventListener("click", function () { + if (self.editMode === "emacs") { + self.setMode("vi"); + } else { + self.setMode("emacs"); + } + }); + + window.addEventListener("resize", function () { + self.fitAddon.fit(); + }); + + this.term.onKey(function (ev) { + self.handleKey(ev.key, ev.domEvent); + }); + + // Prevent xterm from eating paste + container.addEventListener("paste", function (e) { + var text = (e.clipboardData || window.clipboardData).getData("text"); + if (text) { + // In vi command mode, switch to insert before pasting + if (self.editMode === "vi" && self.viMode === "command") { + self.viEnterInsert(); + } + for (var i = 0; i < text.length; i++) { + var ch = text[i]; + if (ch === "\r" || ch === "\n") { + self.submit(); + } else if (ch >= " ") { + self.insertChar(ch); + } + } + } + }); + } + + TermShell.prototype.setMode = function (mode) { + this.editMode = mode; + this.toggleEl.textContent = mode.toUpperCase(); + if (mode === "vi") { + this.viMode = "insert"; + this.viPending = null; + this.term.options.cursorStyle = "bar"; + this.toggleEl.classList.add("vi"); + } else { + this.viMode = "insert"; + this.viPending = null; + this.term.options.cursorStyle = "block"; + this.toggleEl.classList.remove("vi"); + } + }; + + TermShell.prototype.viEnterInsert = function () { + this.viMode = "insert"; + this.viPending = null; + this.term.options.cursorStyle = "bar"; + }; + + TermShell.prototype.viEnterCommand = function () { + this.viMode = "command"; + this.viPending = null; + this.term.options.cursorStyle = "block"; + // Clamp cursor to last char (vi command mode convention) + if (this.cursorPos > 0 && this.cursorPos >= this.line.length) { + this.cursorPos = Math.max(0, this.line.length - 1); + this.refreshLine(); + } + }; + + // Delete a range and store in kill ring + TermShell.prototype.killRange = function (from, to) { + if (from === to) return; + var start = Math.min(from, to); + var end = Math.max(from, to); + this.killRing = this.line.substring(start, end); + this.line = this.line.substring(0, start) + this.line.substring(end); + this.cursorPos = start; + this.refreshLine(); + }; + + TermShell.prototype.prompt = function () { + this.term.write(copperBold("path") + " " + pencil("$") + " "); + this.line = ""; + this.cursorPos = 0; + // Reset vi to insert mode on new prompt + if (this.editMode === "vi") { + this.viEnterInsert(); + } + }; + + TermShell.prototype.refreshLine = function () { + this.term.write("\r"); + this.term.write(copperBold("path") + " " + pencil("$") + " "); + this.term.write(this.line); + this.term.write("\x1b[K"); // clear to end of line + var moveBack = this.line.length - this.cursorPos; + if (moveBack > 0) { + this.term.write("\x1b[" + moveBack + "D"); + } + }; + + TermShell.prototype.insertChar = function (ch) { + this.line = + this.line.substring(0, this.cursorPos) + + ch + + this.line.substring(this.cursorPos); + this.cursorPos++; + this.refreshLine(); + }; + + TermShell.prototype.submit = function () { + this.term.write("\r\n"); + var line = this.line; + if (line.trim()) { + this.history.push(line); + if (this.history.length > 50) this.history.shift(); + } + this.historyIndex = -1; + this.savedLine = ""; + + var self = this; + var result = dispatch(line, this.fs); + if (result && typeof result.then === "function") { + // Async (wasm command) — disable input until done + this.busy = true; + result.then(function (r) { + if (r && r.output != null) self.term.write(r.output + "\r\n"); + self.busy = false; + self.prompt(); + }); + } else { + if (result) { + if (result.clear) { + this.term.clear(); + } else if (result.output != null) { + this.term.write(result.output + "\r\n"); + } + } + this.prompt(); + } + }; + + // --- History navigation (shared) --- + TermShell.prototype.historyPrev = function () { + if (this.history.length === 0) return; + if (this.historyIndex === -1) { + this.savedLine = this.line; + this.historyIndex = this.history.length - 1; + } else if (this.historyIndex > 0) { + this.historyIndex--; + } + this.line = this.history[this.historyIndex]; + this.cursorPos = this.line.length; + this.refreshLine(); + }; + + TermShell.prototype.historyNext = function () { + if (this.historyIndex === -1) return; + if (this.historyIndex < this.history.length - 1) { + this.historyIndex++; + this.line = this.history[this.historyIndex]; + } else { + this.historyIndex = -1; + this.line = this.savedLine; + } + this.cursorPos = this.line.length; + this.refreshLine(); + }; + + // --- Key dispatch --- + TermShell.prototype.handleKey = function (key, domEvent) { + if (this.busy) return; + var code = domEvent.keyCode; + + // Tab - always ignore + if (code === 9) { + domEvent.preventDefault(); + return; + } + + // Enter - always submit + if (code === 13) { + // In vi command mode, move to insert before submitting for clean state + this.submit(); + return; + } + + // Ctrl+C - always cancel + if (domEvent.ctrlKey && code === 67) { + this.term.write("^C\r\n"); + this.prompt(); + return; + } + + // Ctrl+L - always clear + if (domEvent.ctrlKey && code === 76) { + this.term.clear(); + this.refreshLine(); + return; + } + + if (this.editMode === "emacs") { + this.handleEmacs(key, domEvent, code); + } else { + this.handleVi(key, domEvent, code); + } + }; + + // --- Emacs mode --- + TermShell.prototype.handleEmacs = function (key, domEvent, code) { + // --- Ctrl bindings --- + if (domEvent.ctrlKey) { + switch (code) { + case 65: // Ctrl+A: beginning of line + this.cursorPos = 0; + this.refreshLine(); + return; + case 69: // Ctrl+E: end of line + this.cursorPos = this.line.length; + this.refreshLine(); + return; + case 66: // Ctrl+B: back one char + if (this.cursorPos > 0) { + this.cursorPos--; + this.term.write("\x1b[D"); + } + return; + case 70: // Ctrl+F: forward one char + if (this.cursorPos < this.line.length) { + this.cursorPos++; + this.term.write("\x1b[C"); + } + return; + case 68: // Ctrl+D: delete char at cursor (or no-op if empty) + if (this.cursorPos < this.line.length) { + this.line = + this.line.substring(0, this.cursorPos) + + this.line.substring(this.cursorPos + 1); + this.refreshLine(); + } + return; + case 72: // Ctrl+H: backspace + if (this.cursorPos > 0) { + this.line = + this.line.substring(0, this.cursorPos - 1) + + this.line.substring(this.cursorPos); + this.cursorPos--; + this.refreshLine(); + } + return; + case 75: // Ctrl+K: kill to end of line + this.killRing = this.line.substring(this.cursorPos); + this.line = this.line.substring(0, this.cursorPos); + this.refreshLine(); + return; + case 85: // Ctrl+U: kill to beginning of line + this.killRing = this.line.substring(0, this.cursorPos); + this.line = this.line.substring(this.cursorPos); + this.cursorPos = 0; + this.refreshLine(); + return; + case 87: // Ctrl+W: kill previous word + var wb = wordBoundaryLeft(this.line, this.cursorPos); + this.killRange(wb, this.cursorPos); + return; + case 89: // Ctrl+Y: yank + if (this.killRing) { + this.line = + this.line.substring(0, this.cursorPos) + + this.killRing + + this.line.substring(this.cursorPos); + this.cursorPos += this.killRing.length; + this.refreshLine(); + } + return; + case 84: // Ctrl+T: transpose chars + if (this.cursorPos > 0 && this.line.length > 1) { + var p = this.cursorPos; + if (p >= this.line.length) p = this.line.length - 1; + var a = this.line[p - 1]; + var b = this.line[p]; + this.line = + this.line.substring(0, p - 1) + + b + + a + + this.line.substring(p + 1); + this.cursorPos = p + 1; + if (this.cursorPos > this.line.length) + this.cursorPos = this.line.length; + this.refreshLine(); + } + return; + case 80: // Ctrl+P: previous history + this.historyPrev(); + return; + case 78: // Ctrl+N: next history + this.historyNext(); + return; + } + return; + } + + // --- Alt/Meta bindings --- + if (domEvent.altKey || domEvent.metaKey) { + switch (code) { + case 66: // Alt+B: back one word + this.cursorPos = wordBoundaryLeft(this.line, this.cursorPos); + this.refreshLine(); + return; + case 70: // Alt+F: forward one word + this.cursorPos = wordEndRight(this.line, this.cursorPos); + this.refreshLine(); + return; + case 68: // Alt+D: kill forward word + var we = wordEndRight(this.line, this.cursorPos); + this.killRange(this.cursorPos, we); + return; + } + return; + } + + // --- Regular keys --- + switch (code) { + case 8: // Backspace + if (this.cursorPos > 0) { + this.line = + this.line.substring(0, this.cursorPos - 1) + + this.line.substring(this.cursorPos); + this.cursorPos--; + this.refreshLine(); + } + return; + case 46: // Delete + if (this.cursorPos < this.line.length) { + this.line = + this.line.substring(0, this.cursorPos) + + this.line.substring(this.cursorPos + 1); + this.refreshLine(); + } + return; + case 37: // Left + if (this.cursorPos > 0) { + this.cursorPos--; + this.term.write("\x1b[D"); + } + return; + case 39: // Right + if (this.cursorPos < this.line.length) { + this.cursorPos++; + this.term.write("\x1b[C"); + } + return; + case 38: // Up + this.historyPrev(); + return; + case 40: // Down + this.historyNext(); + return; + case 36: // Home + this.cursorPos = 0; + this.refreshLine(); + return; + case 35: // End + this.cursorPos = this.line.length; + this.refreshLine(); + return; + } + + // Printable + if (key.length === 1 && key >= " ") { + this.insertChar(key); + } + }; + + // --- Vi mode --- + TermShell.prototype.handleVi = function (key, domEvent, code) { + if (this.viMode === "insert") { + this.handleViInsert(key, domEvent, code); + } else { + this.handleViCommand(key, domEvent, code); + } + }; + + // Vi insert mode — mostly like basic editing, Escape enters command mode + TermShell.prototype.handleViInsert = function (key, domEvent, code) { + // Escape → command mode + if (code === 27) { + // Step cursor back one (vi convention: cursor sits on last typed char) + if (this.cursorPos > 0) this.cursorPos--; + this.viEnterCommand(); + this.refreshLine(); + return; + } + + // Ctrl bindings still useful in vi insert mode + if (domEvent.ctrlKey) { + switch (code) { + case 85: // Ctrl+U: kill to beginning + this.killRing = this.line.substring(0, this.cursorPos); + this.line = this.line.substring(this.cursorPos); + this.cursorPos = 0; + this.refreshLine(); + return; + case 87: // Ctrl+W: kill previous word + var wb = wordBoundaryLeft(this.line, this.cursorPos); + this.killRange(wb, this.cursorPos); + return; + case 72: // Ctrl+H: backspace + if (this.cursorPos > 0) { + this.line = + this.line.substring(0, this.cursorPos - 1) + + this.line.substring(this.cursorPos); + this.cursorPos--; + this.refreshLine(); + } + return; + } + return; + } + + if (domEvent.altKey || domEvent.metaKey) return; + + // Standard keys + switch (code) { + case 8: // Backspace + if (this.cursorPos > 0) { + this.line = + this.line.substring(0, this.cursorPos - 1) + + this.line.substring(this.cursorPos); + this.cursorPos--; + this.refreshLine(); + } + return; + case 46: // Delete + if (this.cursorPos < this.line.length) { + this.line = + this.line.substring(0, this.cursorPos) + + this.line.substring(this.cursorPos + 1); + this.refreshLine(); + } + return; + case 37: + if (this.cursorPos > 0) { + this.cursorPos--; + this.term.write("\x1b[D"); + } + return; + case 39: + if (this.cursorPos < this.line.length) { + this.cursorPos++; + this.term.write("\x1b[C"); + } + return; + case 38: + this.historyPrev(); + return; + case 40: + this.historyNext(); + return; + case 36: + this.cursorPos = 0; + this.refreshLine(); + return; + case 35: + this.cursorPos = this.line.length; + this.refreshLine(); + return; + } + + // Printable + if (key.length === 1 && key >= " ") { + this.insertChar(key); + } + }; + + // Vi command mode — single-char and operator+motion commands + TermShell.prototype.handleViCommand = function (key, domEvent, code) { + // Escape always cancels pending operator + if (code === 27) { + this.viPending = null; + return; + } + + if (domEvent.ctrlKey || domEvent.altKey || domEvent.metaKey) return; + + // Arrow keys still work in command mode + switch (code) { + case 37: + if (this.cursorPos > 0) { + this.cursorPos--; + this.refreshLine(); + } + this.viPending = null; + return; + case 39: + if (this.cursorPos < this.line.length - 1) { + this.cursorPos++; + this.refreshLine(); + } + this.viPending = null; + return; + case 38: + this.historyPrev(); + this.viPending = null; + return; + case 40: + this.historyNext(); + this.viPending = null; + return; + case 8: // Backspace + if (this.cursorPos > 0) { + this.cursorPos--; + this.refreshLine(); + } + this.viPending = null; + return; + } + + // Only handle single printable chars from here + if (key.length !== 1) return; + + var pending = this.viPending; + + // Handle pending operator + motion + if (pending === "d" || pending === "c") { + this.viPending = null; + var enterInsert = pending === "c"; + switch (key) { + case "w": // delete/change word forward + var we = wordEndRight(this.line, this.cursorPos); + this.killRange(this.cursorPos, we); + if (enterInsert) this.viEnterInsert(); + return; + case "b": // delete/change word backward + var wb = wordBoundaryLeft(this.line, this.cursorPos); + this.killRange(wb, this.cursorPos); + if (enterInsert) this.viEnterInsert(); + return; + case "e": // delete/change to end of word + var wee = wordEndRight(this.line, this.cursorPos); + this.killRange(this.cursorPos, wee); + if (enterInsert) this.viEnterInsert(); + return; + case "W": // delete/change WORD forward + var Wt = WORDBoundaryRight(this.line, this.cursorPos); + this.killRange(this.cursorPos, Wt); + if (enterInsert) this.viEnterInsert(); + return; + case "B": // delete/change WORD backward + var Bt = WORDBoundaryLeft(this.line, this.cursorPos); + this.killRange(Bt, this.cursorPos); + if (enterInsert) this.viEnterInsert(); + return; + case "E": // delete/change to end of WORD + var Et = WORDEndRight(this.line, this.cursorPos); + this.killRange(this.cursorPos, Et); + if (enterInsert) this.viEnterInsert(); + return; + case "$": // delete/change to end of line + this.killRange(this.cursorPos, this.line.length); + if (enterInsert) this.viEnterInsert(); + return; + case "0": // delete/change to beginning of line + this.killRange(0, this.cursorPos); + if (enterInsert) this.viEnterInsert(); + return; + case "d": // dd: delete whole line + if (pending === "d") { + this.killRing = this.line; + this.line = ""; + this.cursorPos = 0; + this.refreshLine(); + } + return; + case "c": // cc: change whole line + if (pending === "c") { + this.killRing = this.line; + this.line = ""; + this.cursorPos = 0; + this.refreshLine(); + this.viEnterInsert(); + } + return; + } + // Unknown motion — cancel + return; + } + + // Single-char commands + switch (key) { + // --- Movement --- + case "h": + if (this.cursorPos > 0) { + this.cursorPos--; + this.refreshLine(); + } + return; + case "l": + if (this.cursorPos < this.line.length - 1) { + this.cursorPos++; + this.refreshLine(); + } + return; + case "w": + this.cursorPos = Math.min( + wordBoundaryRight(this.line, this.cursorPos), + Math.max(0, this.line.length - 1), + ); + this.refreshLine(); + return; + case "b": + this.cursorPos = wordBoundaryLeft(this.line, this.cursorPos); + this.refreshLine(); + return; + case "e": + var ep = wordEndRight(this.line, this.cursorPos); + this.cursorPos = Math.min( + Math.max(0, ep - 1), + Math.max(0, this.line.length - 1), + ); + this.refreshLine(); + return; + case "W": + this.cursorPos = Math.min( + WORDBoundaryRight(this.line, this.cursorPos), + Math.max(0, this.line.length - 1), + ); + this.refreshLine(); + return; + case "B": + this.cursorPos = WORDBoundaryLeft(this.line, this.cursorPos); + this.refreshLine(); + return; + case "E": + var Ep = WORDEndRight(this.line, this.cursorPos); + this.cursorPos = Math.min( + Math.max(0, Ep - 1), + Math.max(0, this.line.length - 1), + ); + this.refreshLine(); + return; + case "0": + this.cursorPos = 0; + this.refreshLine(); + return; + case "^": + // First non-whitespace + var fi = 0; + while ( + fi < this.line.length && + (this.line[fi] === " " || this.line[fi] === "\t") + ) + fi++; + this.cursorPos = Math.min(fi, Math.max(0, this.line.length - 1)); + this.refreshLine(); + return; + case "$": + this.cursorPos = Math.max(0, this.line.length - 1); + this.refreshLine(); + return; + + // --- Insert mode entry --- + case "i": + this.viEnterInsert(); + return; + case "a": + if (this.line.length > 0) + this.cursorPos = Math.min(this.cursorPos + 1, this.line.length); + this.viEnterInsert(); + this.refreshLine(); + return; + case "I": + this.cursorPos = 0; + this.viEnterInsert(); + this.refreshLine(); + return; + case "A": + this.cursorPos = this.line.length; + this.viEnterInsert(); + this.refreshLine(); + return; + case "s": // substitute char: delete + insert + if (this.cursorPos < this.line.length) { + this.killRing = this.line[this.cursorPos]; + this.line = + this.line.substring(0, this.cursorPos) + + this.line.substring(this.cursorPos + 1); + this.refreshLine(); + } + this.viEnterInsert(); + return; + case "S": // substitute line + this.killRing = this.line; + this.line = ""; + this.cursorPos = 0; + this.refreshLine(); + this.viEnterInsert(); + return; + + // --- Deletion --- + case "x": // delete char at cursor + if (this.cursorPos < this.line.length) { + this.killRing = this.line[this.cursorPos]; + this.line = + this.line.substring(0, this.cursorPos) + + this.line.substring(this.cursorPos + 1); + if (this.cursorPos >= this.line.length && this.cursorPos > 0) + this.cursorPos--; + this.refreshLine(); + } + return; + case "X": // delete char before cursor + if (this.cursorPos > 0) { + this.killRing = this.line[this.cursorPos - 1]; + this.line = + this.line.substring(0, this.cursorPos - 1) + + this.line.substring(this.cursorPos); + this.cursorPos--; + this.refreshLine(); + } + return; + case "D": // delete to end of line + this.killRange(this.cursorPos, this.line.length); + if (this.cursorPos > 0 && this.cursorPos >= this.line.length) + this.cursorPos--; + this.refreshLine(); + return; + case "C": // change to end of line + this.killRange(this.cursorPos, this.line.length); + this.viEnterInsert(); + return; + + // --- Operators (wait for motion) --- + case "d": + this.viPending = "d"; + return; + case "c": + this.viPending = "c"; + return; + + // --- Yank/paste --- + case "p": // paste after cursor + if (this.killRing) { + var pos = Math.min(this.cursorPos + 1, this.line.length); + this.line = + this.line.substring(0, pos) + + this.killRing + + this.line.substring(pos); + this.cursorPos = pos + this.killRing.length - 1; + this.refreshLine(); + } + return; + case "P": // paste before cursor + if (this.killRing) { + this.line = + this.line.substring(0, this.cursorPos) + + this.killRing + + this.line.substring(this.cursorPos); + this.cursorPos += this.killRing.length - 1; + this.refreshLine(); + } + return; + + // --- History --- + case "k": + this.historyPrev(); + return; + case "j": + this.historyNext(); + return; + + // --- Replace single char --- + // 'r' would need a follow-up char — skip for simplicity + } + }; + + // Execute a command programmatically and write output + TermShell.prototype.exec = function (line, callback) { + this.term.write(line); + this.term.write("\r\n"); + this.line = line; + if (line.trim()) { + this.history.push(line); + } + var self = this; + var result = dispatch(line, this.fs); + if (result && typeof result.then === "function") { + result.then(function (r) { + if (r && r.output != null) self.term.write(r.output + "\r\n"); + if (callback) callback(); + }); + } else { + if (result) { + if (result.clear) { + this.term.clear(); + } else if (result.output != null) { + this.term.write(result.output + "\r\n"); + } + } + if (callback) callback(); + } + }; + + // Auto-type a command character by character, then execute + TermShell.prototype.autoType = function (line, callback) { + var self = this; + var i = 0; + function typeNext() { + if (i < line.length) { + self.term.write(line[i]); + i++; + setTimeout(typeNext, 30); + } else { + self.term.write("\r\n"); + self.line = line; + if (line.trim()) { + self.history.push(line); + } + var result = dispatch(line, self.fs); + if (result && typeof result.then === "function") { + result.then(function (r) { + if (r && r.output != null) self.term.write(r.output + "\r\n"); + if (callback) callback(); + }); + } else { + if (result && result.output != null) { + self.term.write(result.output + "\r\n"); + } + if (callback) callback(); + } + } + } + typeNext(); + }; + + // --- Boot sequence --- + function boot() { + var el = document.getElementById("playground-terminal"); + if (!el) return; + + var files = window.__PLAYGROUND_FILES__ || {}; + var fs = new VirtualFS(files); + var shell = new TermShell(el, fs); + + // Banner + shell.term.write( + copperBold("TOOLPATH PLAYGROUND") + + " " + + dim("interactive terminal") + + "\r\n", + ); + shell.term.write( + dim("Type help for commands. Example documents are preloaded.") + "\r\n", + ); + shell.term.write("\r\n"); + + // Suggested commands (dimmed) + var suggestions = [ + "path validate --input path-01-pr.json", + "path query dead-ends --input path-01-pr.json --pretty", + 'path query filter --input path-01-pr.json --actor "agent:" --pretty', + ]; + for (var i = 0; i < suggestions.length; i++) { + shell.term.write(dim(" # " + suggestions[i]) + "\r\n"); + } + shell.term.write("\r\n"); + + // Load wasm in background, then auto-type a command + shell.term.write(dim(" Loading CLI...") + "\r\n\r\n"); + loadWasm(files) + .then(function () { + shell.term.write(copperBold("path") + " " + pencil("$") + " "); + shell.autoType( + "path query dead-ends --input path-01-pr.json --pretty", + function () { + shell.prompt(); + }, + ); + }) + .catch(function () { + shell.term.write(red(" Failed to load wasm binary.") + "\r\n\r\n"); + shell.prompt(); + }); + } + + // Initialize when DOM is ready + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", boot); + } else { + boot(); + } +})(); diff --git a/site/js/toolpath-core.js b/site/js/toolpath-core.js new file mode 100644 index 0000000..3531db4 --- /dev/null +++ b/site/js/toolpath-core.js @@ -0,0 +1,199 @@ +// Toolpath Core — shared pure-data logic (no DOM dependencies) +// Extracted from visualizer.js for reuse in playground.js + +(function () { + "use strict"; + + var TC = {}; + + // --- Actor helpers --- + TC.actorType = function (actor) { + var colon = actor.indexOf(":"); + return colon > -1 ? actor.substring(0, colon) : actor; + }; + + TC.actorName = function (actor) { + var colon = actor.indexOf(":"); + return colon > -1 ? actor.substring(colon + 1) : actor; + }; + + TC.resolveActor = function (actorStr, actorDefs) { + if (!actorDefs) return null; + return actorDefs[actorStr] || null; + }; + + TC.actorDisplayName = function (actorStr, actorDefs) { + var def = TC.resolveActor(actorStr, actorDefs); + if (def && def.name) return def.name; + return TC.actorName(actorStr); + }; + + TC.actorIdentitySummary = function (actorStr, actorDefs) { + var def = TC.resolveActor(actorStr, actorDefs); + if (!def) return ""; + var parts = []; + if (def.provider) parts.push(def.provider); + if (def.model) parts.push(def.model); + if (def.identities) { + def.identities.forEach(function (id) { + parts.push(id.system + ":" + id.id); + }); + } + return parts.join(", "); + }; + + // --- String helpers --- + TC.escapeHtml = function (s) { + if (!s) return ""; + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + }; + + TC.truncate = function (s, n) { + if (!s) return ""; + return s.length > n ? s.substring(0, n) + "..." : s; + }; + + // --- Document parsing --- + TC.parseDoc = function (text) { + var doc = JSON.parse(text); + if (doc.Step) return { type: "Step", data: doc }; + if (doc.Path) return { type: "Path", data: doc }; + if (doc.Graph) return { type: "Graph", data: doc }; + throw new Error( + "Unknown document type. Expected top-level key: Step, Path, or Graph.", + ); + }; + + // Normalize into array of { pathInfo, steps, headId, base, actors } clusters + TC.normalizeClusters = function (parsed) { + var clusters = []; + if (parsed.type === "Step") { + var stepMeta = parsed.data.Step.meta || {}; + clusters.push({ + pathInfo: null, + steps: [parsed.data.Step], + headId: null, + base: null, + actors: stepMeta.actors || null, + }); + } else if (parsed.type === "Path") { + var p = parsed.data.Path; + var pathActors = (p.meta && p.meta.actors) || null; + clusters.push({ + pathInfo: p.path, + steps: p.steps, + headId: p.path.head, + base: p.path.base || null, + actors: pathActors, + }); + } else if (parsed.type === "Graph") { + var g = parsed.data.Graph; + var graphActors = (g.meta && g.meta.actors) || null; + (g.paths || []).forEach(function (entry) { + if (entry["$ref"]) { + clusters.push({ + pathInfo: { id: entry["$ref"] }, + steps: [], + headId: null, + base: null, + isRef: true, + actors: graphActors, + }); + } else { + var entryActors = (entry.meta && entry.meta.actors) || graphActors; + clusters.push({ + pathInfo: entry.path, + steps: entry.steps || [], + headId: entry.path.head, + base: entry.path.base || null, + actors: entryActors, + }); + } + }); + } + return clusters; + }; + + // --- DAG queries --- + + // Return set (object) of ancestor step IDs reachable from headId + TC.ancestors = function (steps, headId) { + var stepMap = {}; + steps.forEach(function (s) { + stepMap[s.step.id] = s; + }); + var result = {}; + var stack = [headId]; + while (stack.length > 0) { + var id = stack.pop(); + if (result[id]) continue; + result[id] = true; + var step = stepMap[id]; + if (step && step.step.parents) { + step.step.parents.forEach(function (p) { + stack.push(p); + }); + } + } + return result; + }; + + // Return array of steps that are dead ends (not in ancestor set of headId) + TC.deadEnds = function (steps, headId) { + if (!headId) return []; + var ancestorSet = TC.ancestors(steps, headId); + return steps.filter(function (s) { + return !ancestorSet[s.step.id]; + }); + }; + + // Filter steps whose actor string starts with prefix + TC.filterByActor = function (steps, prefix) { + return steps.filter(function (s) { + return s.step.actor.indexOf(prefix) === 0; + }); + }; + + // Extract {steps, headId, id, meta} from any parsed document type + TC.extractSteps = function (parsed) { + if (parsed.type === "Step") { + return { + steps: [parsed.data.Step], + headId: null, + id: parsed.data.Step.step.id, + meta: parsed.data.Step.meta || null, + }; + } + if (parsed.type === "Path") { + var p = parsed.data.Path; + return { + steps: p.steps, + headId: p.path.head, + id: p.path.id, + meta: p.meta || null, + }; + } + if (parsed.type === "Graph") { + var g = parsed.data.Graph; + var allSteps = []; + (g.paths || []).forEach(function (entry) { + if (!entry["$ref"] && entry.steps) { + allSteps = allSteps.concat(entry.steps); + } + }); + return { + steps: allSteps, + headId: null, + id: g.graph.id, + meta: g.meta || null, + }; + } + return { steps: [], headId: null, id: null, meta: null }; + }; + + window.ToolpathCore = TC; +})(); diff --git a/site/js/visualizer.js b/site/js/visualizer.js index 29bc527..1bd8fa4 100644 --- a/site/js/visualizer.js +++ b/site/js/visualizer.js @@ -148,146 +148,39 @@ var zoomBehavior = null; var svgGroup = null; - // --- Helpers --- - function actorType(actor) { - var colon = actor.indexOf(":"); - return colon > -1 ? actor.substring(0, colon) : actor; - } + // --- Helpers (delegated to ToolpathCore) --- + var TC = window.ToolpathCore; - function actorName(actor) { - var colon = actor.indexOf(":"); - return colon > -1 ? actor.substring(colon + 1) : actor; + function actorType(actor) { + return TC.actorType(actor); } - function actorColors(actor) { var t = actorType(actor); return COLORS[t] || COLORS.tool; } - - // Resolve an actor string to its definition from meta.actors, if available - function resolveActor(actorStr, actorDefs) { - if (!actorDefs) return null; - return actorDefs[actorStr] || null; - } - - // Format actor display name: use definition name if available, else raw name function actorDisplayName(actorStr, actorDefs) { - var def = resolveActor(actorStr, actorDefs); - if (def && def.name) return def.name; - return actorName(actorStr); + return TC.actorDisplayName(actorStr, actorDefs); } - - // Format actor identity summary for tooltip/detail (e.g. "github:akesling") function actorIdentitySummary(actorStr, actorDefs) { - var def = resolveActor(actorStr, actorDefs); - if (!def) return ""; - var parts = []; - if (def.provider) parts.push(def.provider); - if (def.model) parts.push(def.model); - if (def.identities) { - def.identities.forEach(function (id) { - parts.push(id.system + ":" + id.id); - }); - } - return parts.join(", "); + return TC.actorIdentitySummary(actorStr, actorDefs); + } + function resolveActor(actorStr, actorDefs) { + return TC.resolveActor(actorStr, actorDefs); } - function truncate(s, n) { - if (!s) return ""; - return s.length > n ? s.substring(0, n) + "..." : s; + return TC.truncate(s, n); } - function escapeHtml(s) { - var d = document.createElement("div"); - d.textContent = s; - return d.innerHTML; + return TC.escapeHtml(s); } - - // --- Dead-end detection (port of query::ancestors) --- function ancestors(steps, headId) { - var stepMap = {}; - steps.forEach(function (s) { - stepMap[s.step.id] = s; - }); - var result = {}; - var stack = [headId]; - while (stack.length > 0) { - var id = stack.pop(); - if (result[id]) continue; - result[id] = true; - var step = stepMap[id]; - if (step && step.step.parents) { - step.step.parents.forEach(function (p) { - stack.push(p); - }); - } - } - return result; + return TC.ancestors(steps, headId); } - - // --- Parse document --- function parseDoc(text) { - var doc = JSON.parse(text); - // Detect document type by top-level key - if (doc.Step) return { type: "Step", data: doc }; - if (doc.Path) return { type: "Path", data: doc }; - if (doc.Graph) return { type: "Graph", data: doc }; - throw new Error( - "Unknown document type. Expected top-level key: Step, Path, or Graph.", - ); + return TC.parseDoc(text); } - - // Normalize into array of { pathInfo, steps } clusters function normalizeClusters(parsed) { - var clusters = []; - if (parsed.type === "Step") { - // Single step, no path context - var stepMeta = parsed.data.Step.meta || {}; - clusters.push({ - pathInfo: null, - steps: [parsed.data.Step], - headId: null, - base: null, - actors: stepMeta.actors || null, - }); - } else if (parsed.type === "Path") { - var p = parsed.data.Path; - var pathActors = (p.meta && p.meta.actors) || null; - clusters.push({ - pathInfo: p.path, - steps: p.steps, - headId: p.path.head, - base: p.path.base || null, - actors: pathActors, - }); - } else if (parsed.type === "Graph") { - var g = parsed.data.Graph; - var graphActors = (g.meta && g.meta.actors) || null; - (g.paths || []).forEach(function (entry) { - // entry can be a Path object or a { "$ref": ... } - if (entry["$ref"]) { - clusters.push({ - pathInfo: { id: entry["$ref"] }, - steps: [], - headId: null, - base: null, - isRef: true, - actors: graphActors, - }); - } else { - // Path-level actors override graph-level - var entryActors = (entry.meta && entry.meta.actors) || graphActors; - clusters.push({ - pathInfo: entry.path, - steps: entry.steps || [], - headId: entry.path.head, - base: entry.path.base || null, - actors: entryActors, - }); - } - }); - } - return clusters; + return TC.normalizeClusters(parsed); } // --- Render graph --- diff --git a/site/pages/visualizer.njk b/site/pages/visualizer.njk index 1fed4f0..767610e 100644 --- a/site/pages/visualizer.njk +++ b/site/pages/visualizer.njk @@ -66,4 +66,5 @@ permalink: /visualizer/ + From 2c16916024716472281bddf4f90d3e354d126343 Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Tue, 17 Feb 2026 21:06:35 -0500 Subject: [PATCH 3/6] feat: add "pinned commands" to terminal --- site/css/playground.css | 33 ++++++++++++++++++ site/js/playground.js | 76 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/site/css/playground.css b/site/css/playground.css index 7a3c257..14afca7 100644 --- a/site/css/playground.css +++ b/site/css/playground.css @@ -81,6 +81,39 @@ border-color: var(--copper); } +/* Pinned command header (sticky fold) */ +.playground-pinned-cmd { + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 5; + background: var(--grain); + font-family: "IBM Plex Mono", monospace; + font-size: 14px; + padding: 0.75rem; + padding-bottom: 0.35rem; + border-bottom: 1px solid var(--border); + box-shadow: 0 1px 3px rgba(45, 42, 38, 0.08); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + pointer-events: none; +} + +.playground-pinned-cmd .pinned-prompt { + color: var(--copper); + font-weight: bold; +} + +.playground-pinned-cmd .pinned-dollar { + color: var(--pencil); +} + +.playground-pinned-cmd .pinned-text { + color: var(--ink); +} + @media (max-width: 640px) { .playground-terminal .xterm { padding: 0.5rem; diff --git a/site/js/playground.js b/site/js/playground.js index a206ac5..426a1d0 100644 --- a/site/js/playground.js +++ b/site/js/playground.js @@ -403,6 +403,9 @@ window.addEventListener("resize", function () { self.fitAddon.fit(); + var screen = container.querySelector(".xterm-screen"); + if (screen) self.cellHeight = screen.clientHeight / self.term.rows; + self.updatePinned(); }); this.term.onKey(function (ev) { @@ -427,6 +430,42 @@ } } }); + + // Pinned command header (sticky fold) + this.pinnedEl = document.createElement("div"); + this.pinnedEl.className = "playground-pinned-cmd"; + this.pinnedEl.hidden = true; + this.pinnedPromptEl = document.createElement("span"); + this.pinnedPromptEl.className = "pinned-prompt"; + this.pinnedPromptEl.textContent = "path"; + this.pinnedDollarEl = document.createElement("span"); + this.pinnedDollarEl.className = "pinned-dollar"; + this.pinnedDollarEl.textContent = " $ "; + this.pinnedTextEl = document.createElement("span"); + this.pinnedTextEl.className = "pinned-text"; + this.pinnedEl.appendChild(this.pinnedPromptEl); + this.pinnedEl.appendChild(this.pinnedDollarEl); + this.pinnedEl.appendChild(this.pinnedTextEl); + container.appendChild(this.pinnedEl); + + this.cmdPositions = []; + + // Scroll listener for pinned header + setTimeout(function () { + self.viewport = container.querySelector(".xterm-viewport"); + var screen = container.querySelector(".xterm-screen"); + if (screen) self.cellHeight = screen.clientHeight / self.term.rows; + if (self.viewport) { + self.viewport.addEventListener("scroll", function () { + self.updatePinned(); + }); + } + }, 0); + this.term.onScroll(function () { + setTimeout(function () { + self.updatePinned(); + }, 0); + }); } TermShell.prototype.setMode = function (mode) { @@ -503,7 +542,38 @@ this.refreshLine(); }; + TermShell.prototype.pinCommand = function (cmd) { + this.cmdPositions.push({ + cmd: cmd, + absY: this.term.buffer.active.baseY + this.term.buffer.active.cursorY, + }); + this.updatePinned(); + }; + + TermShell.prototype.updatePinned = function () { + if (!this.cmdPositions.length || !this.viewport || !this.cellHeight) { + this.pinnedEl.hidden = true; + return; + } + var topLine = Math.floor(this.viewport.scrollTop / this.cellHeight); + // Find the last command whose prompt scrolled above the viewport + var pinned = null; + for (var i = this.cmdPositions.length - 1; i >= 0; i--) { + if (this.cmdPositions[i].absY < topLine) { + pinned = this.cmdPositions[i]; + break; + } + } + if (pinned) { + this.pinnedTextEl.textContent = pinned.cmd; + this.pinnedEl.hidden = false; + } else { + this.pinnedEl.hidden = true; + } + }; + TermShell.prototype.submit = function () { + if (this.line.trim()) this.pinCommand(this.line); this.term.write("\r\n"); var line = this.line; if (line.trim()) { @@ -527,6 +597,8 @@ if (result) { if (result.clear) { this.term.clear(); + this.cmdPositions = []; + this.pinnedEl.hidden = true; } else if (result.output != null) { this.term.write(result.output + "\r\n"); } @@ -590,6 +662,8 @@ // Ctrl+L - always clear if (domEvent.ctrlKey && code === 76) { this.term.clear(); + this.cmdPositions = []; + this.pinnedEl.hidden = true; this.refreshLine(); return; } @@ -1172,6 +1246,7 @@ // Execute a command programmatically and write output TermShell.prototype.exec = function (line, callback) { this.term.write(line); + if (line.trim()) this.pinCommand(line); this.term.write("\r\n"); this.line = line; if (line.trim()) { @@ -1206,6 +1281,7 @@ i++; setTimeout(typeNext, 30); } else { + if (line.trim()) self.pinCommand(line); self.term.write("\r\n"); self.line = line; if (line.trim()) { From 5a511f4bcbb6f4814f70b9ff21a38b3d49107957 Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Tue, 17 Feb 2026 21:12:47 -0500 Subject: [PATCH 4/6] feat: add faux cargo preamble --- .cargo/config.toml | 2 +- crates/toolpath-cli/src/main.rs | 2 +- site/js/playground.js | 103 ++++++++++++++++++++------------ 3 files changed, 66 insertions(+), 41 deletions(-) diff --git a/.cargo/config.toml b/.cargo/config.toml index 6f6aba9..69e42b3 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,7 +1,7 @@ [target.wasm32-unknown-emscripten] rustflags = [ "-C", "link-arg=-sINVOKE_RUN=0", - "-C", "link-arg=-sEXIT_RUNTIME=0", + "-C", "link-arg=-sEXIT_RUNTIME=1", "-C", "link-arg=-sMODULARIZE=1", "-C", "link-arg=-sEXPORT_NAME=createPathModule", "-C", "link-arg=-sEXPORTED_RUNTIME_METHODS=callMain,FS", diff --git a/crates/toolpath-cli/src/main.rs b/crates/toolpath-cli/src/main.rs index f7f2cbd..fb6ef38 100644 --- a/crates/toolpath-cli/src/main.rs +++ b/crates/toolpath-cli/src/main.rs @@ -12,7 +12,7 @@ use clap::{Parser, Subcommand}; use std::path::PathBuf; #[derive(Parser, Debug)] -#[command(name = "path")] +#[command(name = "path", version)] #[command(about = "Derive, query, and visualize Toolpath provenance documents")] struct Cli { #[command(subcommand)] diff --git a/site/js/playground.js b/site/js/playground.js index 426a1d0..cbee4e3 100644 --- a/site/js/playground.js +++ b/site/js/playground.js @@ -96,6 +96,7 @@ var wasmFiles = null; var wasmReady = false; var wasmError = null; + var cliVersion = null; function loadWasm(files) { wasmFiles = files; @@ -112,6 +113,12 @@ compiledWasm = mod; wasmReady = true; }) + .then(function () { + return runPath(["--version"]).then(function (result) { + var m = (result.stdout || "").match(/(\d+\.\d+\.\d+\S*)/); + if (m) cliVersion = m[1]; + }); + }) .catch(function (err) { wasmError = err.message || String(err); throw err; @@ -236,6 +243,37 @@ if (cmd === "ls") return { output: cmdLs(fs) }; if (cmd === "cat") return { output: cmdCat(fs, tokens) }; + if (cmd === "cargo") { + var v = cliVersion || "0.x.x"; + if (tokens.indexOf("--force") !== -1 || tokens.indexOf("-f") !== -1) { + return { + output: + ANSI.green + + ANSI.bold + + " Compiling" + + ANSI.reset + + " stubbornness v1.0.0\r\n" + + ANSI.green + + ANSI.bold + + " Finished" + + ANSI.reset + + " cargo pushed really hard but `toolpath-cli v" + + v + + "` is still installed", + }; + } + return { + output: + ANSI.green + + ANSI.bold + + " Ignored" + + ANSI.reset + + " package `toolpath-cli v" + + v + + "` is already installed, use --force to override", + }; + } + if (cmd === "path") { if (!wasmReady) { if (wasmError) { @@ -1313,45 +1351,32 @@ var fs = new VirtualFS(files); var shell = new TermShell(el, fs); - // Banner - shell.term.write( - copperBold("TOOLPATH PLAYGROUND") + - " " + - dim("interactive terminal") + - "\r\n", - ); - shell.term.write( - dim("Type help for commands. Example documents are preloaded.") + "\r\n", - ); - shell.term.write("\r\n"); - - // Suggested commands (dimmed) - var suggestions = [ - "path validate --input path-01-pr.json", - "path query dead-ends --input path-01-pr.json --pretty", - 'path query filter --input path-01-pr.json --actor "agent:" --pretty', - ]; - for (var i = 0; i < suggestions.length; i++) { - shell.term.write(dim(" # " + suggestions[i]) + "\r\n"); - } - shell.term.write("\r\n"); - - // Load wasm in background, then auto-type a command - shell.term.write(dim(" Loading CLI...") + "\r\n\r\n"); - loadWasm(files) - .then(function () { - shell.term.write(copperBold("path") + " " + pencil("$") + " "); - shell.autoType( - "path query dead-ends --input path-01-pr.json --pretty", - function () { - shell.prompt(); - }, - ); - }) - .catch(function () { - shell.term.write(red(" Failed to load wasm binary.") + "\r\n\r\n"); - shell.prompt(); - }); + // Show cargo install, then load wasm in background + shell.term.write(copperBold("path") + " " + pencil("$") + " "); + shell.autoType("cargo install toolpath-cli", function () { + shell.term.write("\r\n"); + shell.term.write( + dim("Type help for commands. Example documents are preloaded.") + + "\r\n", + ); + shell.term.write("\r\n"); + + shell.term.write(dim(" Loading CLI...") + "\r\n\r\n"); + loadWasm(files) + .then(function () { + shell.term.write(copperBold("path") + " " + pencil("$") + " "); + shell.autoType( + "path query dead-ends --input path-01-pr.json --pretty", + function () { + shell.prompt(); + }, + ); + }) + .catch(function () { + shell.term.write(red(" Failed to load wasm binary.") + "\r\n\r\n"); + shell.prompt(); + }); + }); } // Initialize when DOM is ready From e369e225bf088d8153f0f6b1fedb5a970842bfe2 Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Tue, 17 Feb 2026 21:15:48 -0500 Subject: [PATCH 5/6] feat: hid "try it" behind an activation button --- site/css/style.css | 24 ++++++++++++++++++++++++ site/index.md | 3 ++- site/js/playground.js | 22 +++++++++++++++++++--- 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/site/css/style.css b/site/css/style.css index e1ce579..b903b07 100644 --- a/site/css/style.css +++ b/site/css/style.css @@ -307,6 +307,30 @@ th { color: var(--pencil); user-select: none; } +.try-it-btn { + font-family: var(--mono); + font-size: 0.78rem; + font-weight: 600; + letter-spacing: 0.04em; + color: var(--copper); + background: transparent; + border: 1px solid var(--copper); + padding: 0.25rem 0.7rem; + margin-left: 1rem; + cursor: pointer; + transition: + background 0.15s, + color 0.15s; + vertical-align: middle; +} +.try-it-btn:hover { + background: var(--copper); + color: var(--ground); +} +.try-it-btn:disabled { + opacity: 0.4; + cursor: default; +} .hero + .divider { margin-bottom: 2rem; } diff --git a/site/index.md b/site/index.md index 2dd06a6..62f98a1 100644 --- a/site/index.md +++ b/site/index.md @@ -13,6 +13,7 @@ nav: home

$ cargo install toolpath-cli +
-
+