diff --git a/Cargo.toml b/Cargo.toml index c73c5b9..0d59832 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ validate = ["dep:rust-embed"] deploy = [] embed = ["dep:rust-embed"] dashboard = ["full", "dep:axum", "dep:tokio", "dep:askama", "dep:tower-http", "dep:open"] -tui = ["dep:ratatui", "dep:crossterm", "dep:syntect", "dep:ansi-to-tui"] +tui = ["dep:ratatui", "dep:crossterm", "dep:syntect", "dep:ansi-to-tui", "dep:unicode-width"] [dependencies] # cli @@ -65,6 +65,7 @@ ratatui = { version = "0.30", optional = true } crossterm = { version = "0.29", optional = true } syntect = { version = "5.3", default-features = false, features = ["default-syntaxes", "default-themes", "regex-fancy"], optional = true } ansi-to-tui = { version = "8.0.1", optional = true } +unicode-width = { version = "0.2", optional = true } [build-dependencies] sha2 = "0.10" diff --git a/src/cli/dashboard/routes/artifact.rs b/src/cli/dashboard/routes/artifact.rs index 5a76c03..f382ce7 100644 --- a/src/cli/dashboard/routes/artifact.rs +++ b/src/cli/dashboard/routes/artifact.rs @@ -212,6 +212,7 @@ pub(super) async fn companion_detail( kind: "skills".to_string(), module: module_name, relative_path: companion.relative_path.clone(), + source_path: companion.relative_path.clone(), description: companion.description.clone(), content_preview: String::new(), content_body: companion.content_body.clone(), @@ -234,6 +235,7 @@ pub(super) async fn companion_detail( module_tint: 0, companions: Vec::new(), variants: Vec::new(), + vcs: None, }; let companion_label = format!("{parent} / {name}"); let provenance_raw = scan::read_source_sidecar( @@ -654,6 +656,8 @@ mod tests { module_tint: 0, companions: Vec::new(), variants: Vec::new(), + source_path: String::new(), + vcs: None, } } @@ -665,6 +669,9 @@ mod tests { source_uri: format!("https://example.com/{name}"), is_target: false, artifacts, + local_path: None, + vcs: None, + git_log: Vec::new(), } } diff --git a/src/cli/deploy/mod.rs b/src/cli/deploy/mod.rs index 688960b..f69670f 100644 --- a/src/cli/deploy/mod.rs +++ b/src/cli/deploy/mod.rs @@ -21,6 +21,7 @@ use crate::cli::config; /// Modified → skip (unless --force) /// ``` #[allow(clippy::fn_params_excessive_bools, clippy::too_many_lines)] +#[allow(clippy::too_many_arguments)] pub fn execute( path: &str, target: Option<&str>, @@ -29,7 +30,11 @@ pub fn execute( prune: bool, _interactive: bool, dry_run: bool, + only: Option<&str>, ) -> Result { + // A scoped deploy leaves everything else at the target alone: pruning + // against a filtered key set would quarantine the rest of the module. + let prune = prune && only.is_none(); let module_root = Path::new(path); require_module_root(module_root)?; let mut result = ActionResult::new(); @@ -72,9 +77,10 @@ pub fn execute( validate_target_boundary(&target_base, Path::new(dir))?; } deployed_by_root.entry(target_base.clone()).or_default(); - manifests - .entry(target_base.clone()) - .or_insert_with(|| load_deployed_manifest(&target_base)); + if !manifests.contains_key(&target_base) { + let entries = load_manifest_or_recover(&target_base, only)?; + manifests.insert(target_base.clone(), entries); + } } for kind in commands::provider::ContentKind::ALL { @@ -89,9 +95,13 @@ pub fn execute( validate_target_boundary(&target_base, Path::new(dir))?; } - let existing_manifest = manifests - .entry(target_base.clone()) - .or_insert_with(|| load_deployed_manifest(&target_base)); + if !manifests.contains_key(&target_base) { + let entries = load_manifest_or_recover(&target_base, only)?; + manifests.insert(target_base.clone(), entries); + } + let Some(existing_manifest) = manifests.get_mut(&target_base) else { + continue; + }; let deployed_keys = deployed_by_root.entry(target_base.clone()).or_default(); deploy_provider_kind_files( @@ -103,6 +113,7 @@ pub fn execute( &mut result, provider_name, force, + only, )?; } @@ -134,6 +145,32 @@ fn resolve_target_base(target_root: &str, effective_target: Option<&str>) -> Pat } } +/// Whether a manifest key falls under an `--only` prefix. A prefix ending in +/// `/` or `.` matches literally; a bare prefix (`skills/Alpha`) matches only +/// at a path or extension boundary, so `skills/AlphaOther/` stays untouched. +fn only_matches(manifest_key: &str, prefix: &str) -> bool { + // Providers rename artifacts during assembly (gemini slugifies + // SecurityArchitect to security-architect); compare shapes that survive + // the rename: lowercase with separators dropped. + let key = normalize_only(manifest_key); + let prefix = normalize_only(prefix); + if prefix.ends_with('/') || prefix.ends_with('.') { + return key.starts_with(&prefix); + } + key == prefix + || key + .strip_prefix(&prefix) + .is_some_and(|rest| rest.starts_with('/') || rest.starts_with('.')) +} + +fn normalize_only(value: &str) -> String { + value + .chars() + .filter(|character| *character != '-' && *character != '_') + .flat_map(char::to_lowercase) + .collect() +} + /// Deploy one content kind for a single provider. #[allow(clippy::too_many_arguments)] fn deploy_provider_kind_files( @@ -145,6 +182,7 @@ fn deploy_provider_kind_files( result: &mut ActionResult, provider_name: &str, force: bool, + only: Option<&str>, ) -> Result<(), Error> { let files = collect_files_recursive(kind_dir)?; @@ -159,6 +197,9 @@ fn deploy_provider_kind_files( .to_string_lossy() .to_string(); let manifest_key = format!("{kind}/{relative}"); + if only.is_some_and(|prefix| !only_matches(&manifest_key, prefix)) { + continue; + } deployed_keys.insert(manifest_key.clone()); let target_path = target_base.join(kind.as_str()).join(&relative); @@ -167,11 +208,6 @@ fn deploy_provider_kind_files( let provenance_relative = manifest::provenance_path(&manifest_key); let sidecar_source = manifest::sidecar_path(&build_path); - if sidecar_source.is_file() { - let provenance_target = target_base.join(&provenance_relative); - let _ = copy_file(&sidecar_source, &provenance_target); - } - let target_content = fs::read_to_string(&target_path).ok(); let status = manifest::status( target_content.as_deref(), @@ -181,7 +217,19 @@ fn deploy_provider_kind_files( match status { manifest::FileStatus::New | manifest::FileStatus::Stale => { + ensure_destination_within(&target_path, target_base)?; copy_file(&build_path, &target_path)?; + // Provenance travels only with content that actually + // installed; a skipped modified file keeps its old sidecar. + if sidecar_source.is_file() { + let provenance_target = target_base.join(&provenance_relative); + if let Err(error) = copy_file(&sidecar_source, &provenance_target) { + eprintln!( + "warning: sidecar copy failed for {}: {error}", + provenance_target.display() + ); + } + } new_manifest.insert( manifest_key, manifest::ManifestEntry { @@ -236,7 +284,6 @@ fn deploy_provider_kind_files( } Ok(()) } - #[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_lines)] fn prune_stale_files( @@ -434,7 +481,10 @@ fn require_module_root(module_root: &Path) -> Result<(), Error> { } /// Verify the resolved target path stays within the specified base directory. +/// Containment is checked against the deepest existing ancestor BEFORE any +/// directory is created, so an escaping path never mutates the filesystem. fn validate_target_boundary(target_path: &Path, base_directory: &Path) -> Result<(), Error> { + ensure_destination_within(&target_path.join("probe"), base_directory)?; fs::create_dir_all(target_path).map_err(|error| { Error::new( ErrorKind::Io, @@ -471,19 +521,38 @@ fn validate_target_boundary(target_path: &Path, base_directory: &Path) -> Result /// Load the previously deployed `.manifest` from a provider's target directory. pub(crate) fn load_deployed_manifest( target_base: &Path, -) -> HashMap { +) -> Result, Error> { let manifest_path = target_base.join(".manifest"); let Ok(content) = fs::read_to_string(&manifest_path) else { - return HashMap::new(); + return Ok(HashMap::new()); }; - match manifest::read(&content) { - Ok(entries) => entries, + manifest::read(&content).map_err(|error| { + Error::new( + ErrorKind::Config, + format!("corrupt .manifest at {}: {error}", manifest_path.display()), + ) + }) +} + +/// Manifest for a filtered deploy must parse: silently rebuilding it from a +/// partial deploy would drop every entry outside the filter. A full deploy +/// may rebuild with a warning. +fn load_manifest_or_recover( + target_base: &Path, + only: Option<&str>, +) -> Result, Error> { + match load_deployed_manifest(target_base) { + Ok(entries) => Ok(entries), + Err(error) if only.is_some() => Err(Error::new( + ErrorKind::Config, + format!( + "refusing filtered deploy over a corrupt manifest ({error}); \ + run a full install to rebuild it" + ), + )), Err(error) => { - eprintln!( - "warning: corrupt .manifest at {}: {error}", - manifest_path.display() - ); - HashMap::new() + eprintln!("warning: {error}; rebuilding manifest from this deploy"); + Ok(HashMap::new()) } } } @@ -508,6 +577,46 @@ fn write_manifest( .map_err(|e| Error::new(ErrorKind::Io, format!("cannot write .manifest: {e}"))) } +/// Verify that writing `target_path` cannot escape `base`: the deepest +/// existing ancestor (which any symlinked component resolves through) must +/// canonicalize inside the base directory. +fn ensure_destination_within(target_path: &Path, base: &Path) -> Result<(), Error> { + fs::create_dir_all(base).map_err(|error| { + Error::new( + ErrorKind::Io, + format!("cannot create {}: {error}", base.display()), + ) + })?; + let resolved_base = base.canonicalize().map_err(|error| { + Error::new( + ErrorKind::Io, + format!("cannot resolve {}: {error}", base.display()), + ) + })?; + let existing = target_path + .ancestors() + .find(|ancestor| ancestor.exists()) + .unwrap_or(base); + let resolved = existing.canonicalize().map_err(|error| { + Error::new( + ErrorKind::Io, + format!("cannot resolve {}: {error}", existing.display()), + ) + })?; + if resolved.starts_with(&resolved_base) { + Ok(()) + } else { + Err(Error::new( + ErrorKind::Config, + format!( + "destination escapes target: {} resolves to {}", + target_path.display(), + resolved.display() + ), + )) + } +} + /// Copy a file, creating parent directories as needed. fn copy_file(source: &Path, target: &Path) -> Result<(), Error> { if let Some(parent) = target.parent() { diff --git a/src/cli/deploy/tests.rs b/src/cli/deploy/tests.rs index fb44876..cecaa16 100644 --- a/src/cli/deploy/tests.rs +++ b/src/cli/deploy/tests.rs @@ -47,7 +47,7 @@ fn collect_files_recursive_errors_on_missing_directory() { #[test] fn load_deployed_manifest_returns_empty_for_missing_file() { let temp_directory = TempDir::new().unwrap(); - let manifest = load_deployed_manifest(temp_directory.path()); + let manifest = load_deployed_manifest(temp_directory.path()).unwrap(); assert!(manifest.is_empty()); } @@ -80,7 +80,7 @@ fn write_then_load_manifest_roundtrips() { ); write_manifest(temp_directory.path(), &entries).unwrap(); - let loaded = load_deployed_manifest(temp_directory.path()); + let loaded = load_deployed_manifest(temp_directory.path()).unwrap(); assert_eq!(loaded["rules/UseRTK.md"].fingerprint, "abc123"); } @@ -201,3 +201,74 @@ fn prune_empty_parents_never_removes_stop() { assert!(!nested.exists()); assert!(stop.exists(), "stop directory must never be removed"); } + +#[test] +fn deploy_provider_files_only_prefix_filters_deployment() { + let temp_directory = TempDir::new().unwrap(); + let build_dir = temp_directory.path().join("build/claude"); + std::fs::create_dir_all(build_dir.join("skills/Alpha")).unwrap(); + std::fs::create_dir_all(build_dir.join("skills/Beta")).unwrap(); + std::fs::write(build_dir.join("skills/Alpha/SKILL.md"), "alpha body").unwrap(); + std::fs::write(build_dir.join("skills/Beta/SKILL.md"), "beta body").unwrap(); + let target = temp_directory.path().join("target"); + + let mut manifest_entries = HashMap::new(); + let mut deployed_keys = HashSet::new(); + let mut result = ActionResult::new(); + deploy_provider_kind_files( + &build_dir.join("skills"), + commands::provider::ContentKind::Skills, + &target, + &mut manifest_entries, + &mut deployed_keys, + &mut result, + "claude", + false, + Some("skills/Alpha/"), + ) + .unwrap(); + + assert!(target.join("skills/Alpha/SKILL.md").is_file()); + assert!(!target.join("skills/Beta/SKILL.md").exists()); + assert!(deployed_keys.contains("skills/Alpha/SKILL.md")); + assert!(!deployed_keys.contains("skills/Beta/SKILL.md")); + assert_eq!( + std::fs::read_to_string(target.join("skills/Alpha/SKILL.md")).unwrap(), + "alpha body" + ); +} + +#[test] +fn only_matches_respects_boundaries() { + assert!(only_matches("skills/Alpha/SKILL.md", "skills/Alpha/")); + assert!(only_matches("skills/Alpha/SKILL.md", "skills/Alpha")); + assert!(!only_matches("skills/AlphaOther/SKILL.md", "skills/Alpha")); + assert!(only_matches("agents/Name.md", "agents/Name.")); + assert!(only_matches("agents/Name.toml", "agents/Name")); + assert!(!only_matches("agents/NameOther.md", "agents/Name")); +} + +#[test] +fn only_matches_survives_provider_slugging() { + assert!(only_matches( + "agents/security-architect.md", + "agents/SecurityArchitect" + )); + assert!(!only_matches( + "agents/security-architect-two.md", + "agents/SecurityArchitect" + )); +} + +#[test] +fn ensure_destination_within_rejects_symlink_escape() { + let temp_directory = TempDir::new().unwrap(); + let base = temp_directory.path().join("base"); + let outside = temp_directory.path().join("outside"); + std::fs::create_dir_all(base.join("skills")).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + std::os::unix::fs::symlink(&outside, base.join("skills/Escape")).unwrap(); + + assert!(ensure_destination_within(&base.join("skills/Inside/SKILL.md"), &base).is_ok()); + assert!(ensure_destination_within(&base.join("skills/Escape/SKILL.md"), &base).is_err()); +} diff --git a/src/cli/drift/scope.rs b/src/cli/drift/scope.rs index 55c14ba..3dd535f 100644 --- a/src/cli/drift/scope.rs +++ b/src/cli/drift/scope.rs @@ -135,7 +135,10 @@ fn compare_provider( // are no longer built — stale deployments that should be pruned. for target_root in provider_config.target_roots() { let deployed_base = target_base.join(target_root); - for (key, entry) in load_deployed_manifest(&deployed_base) { + for (key, entry) in load_deployed_manifest(&deployed_base).unwrap_or_else(|error| { + eprintln!("warning: {error}; treating manifest as empty for drift"); + std::collections::HashMap::new() + }) { if !is_content_key(&key) { continue; } diff --git a/src/cli/install/mod.rs b/src/cli/install/mod.rs index be38ca6..316f015 100644 --- a/src/cli/install/mod.rs +++ b/src/cli/install/mod.rs @@ -29,6 +29,7 @@ pub fn execute( prune: bool, interactive: bool, dry_run: bool, + only: Option<&str>, model: Option<&str>, allow_stale: bool, ) -> Result { @@ -42,6 +43,7 @@ pub fn execute( prune, interactive, dry_run, + only, ) } diff --git a/src/cli/install/tests.rs b/src/cli/install/tests.rs index 252eec5..fc4e2bd 100644 --- a/src/cli/install/tests.rs +++ b/src/cli/install/tests.rs @@ -20,6 +20,7 @@ fn execute_errors_on_missing_module() { false, false, None, + None, false, ); assert!(result.is_err()); @@ -37,6 +38,7 @@ fn execute_errors_on_directory_without_module_yaml() { false, false, None, + None, false, ); let error = result.expect_err("expected install to refuse non-module directory"); @@ -71,6 +73,7 @@ fn execute_succeeds_on_empty_module() { false, false, None, + None, false, ); assert!(result.is_ok()); @@ -91,6 +94,7 @@ fn execute_unknown_provider_lists_available_choices() { false, false, None, + None, false, ); let error = result.expect_err("unknown provider must error"); @@ -127,6 +131,7 @@ fn execute_provider_filter_skips_unrequested_providers() { false, false, None, + None, false, ) .expect("install should succeed for known provider"); diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 7f79524..0a3c204 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -45,7 +45,29 @@ struct Cli { enum Command { /// Launch the terminal dashboard #[cfg(feature = "tui")] - Tui, + Tui { + /// Render one frame to stdout as text (headless layout inspection). + #[arg(long)] + snapshot: bool, + /// Snapshot width in columns. + #[arg(long, default_value = "120")] + width: u16, + /// Snapshot height in rows. + #[arg(long, default_value = "40")] + height: u16, + /// Section number (1-based) to display in the snapshot. + #[arg(long)] + section: Option, + /// Detail tab: preview|code|diff|provenance|frontmatter|history|companions. + #[arg(long)] + tab: Option, + /// Drill right N times (0 = sections focus, 1 = list, 2 = detail). + #[arg(long, default_value = "0")] + drill: u8, + /// Move the list selection down N rows before drilling into detail. + #[arg(long, default_value = "0")] + row: usize, + }, /// Initialize a new forge module with required files and schemas Init { @@ -109,6 +131,11 @@ enum Command { #[arg(long)] allow_stale: bool, + /// Deploy only files under this module-relative prefix + /// (e.g. `skills/Name/` or `agents/Name.`). Implies --no-prune. + #[arg(long, value_name = "PREFIX")] + only: Option, + /// Override each provider's default model when selecting /// `provider//` qualifier variants (exact model ID from /// config/models.yaml; ignored for providers that lack it). @@ -163,6 +190,11 @@ enum Command { /// Show what would be pruned without moving files. #[arg(long)] dry_run: bool, + + /// Deploy only files under this module-relative prefix + /// (e.g. `skills/Name/` or `agents/Name.`). Implies --no-prune. + #[arg(long, value_name = "PREFIX")] + only: Option, }, /// Copy source files directly to a target directory (no assembly, no transforms) @@ -375,7 +407,21 @@ pub fn run() -> i32 { let (result, verb) = match command { #[cfg(feature = "tui")] - Command::Tui => return crate::tui::run(), + Command::Tui { + snapshot, + width, + height, + section, + tab, + drill, + row, + } => { + return if snapshot { + crate::tui::run_snapshot(width, height, section, tab.as_deref(), drill, row) + } else { + crate::tui::run() + }; + } Command::Init { target } => (init::execute(&target), "initialized"), Command::Install { source, @@ -386,6 +432,7 @@ pub fn run() -> i32 { no_prune, dry_run, allow_stale, + only, model, } => ( install::execute( @@ -396,6 +443,7 @@ pub fn run() -> i32 { !no_prune, interactive, dry_run, + only.as_deref(), model.as_deref(), allow_stale, ), @@ -413,6 +461,7 @@ pub fn run() -> i32 { interactive, no_prune, dry_run, + only, } => ( deploy::execute( &source, @@ -422,6 +471,7 @@ pub fn run() -> i32 { !no_prune, interactive, dry_run, + only.as_deref(), ), "deployed", ), @@ -458,7 +508,16 @@ pub fn run() -> i32 { )); } Command::Clean { source, target } => ( - deploy::execute(&source, target.as_deref(), &[], false, true, false, false), + deploy::execute( + &source, + target.as_deref(), + &[], + false, + true, + false, + false, + None, + ), "cleaned", ), Command::Config => return exit_code(ontology::show(args.json)), diff --git a/src/cli/release/mod.rs b/src/cli/release/mod.rs index e29b674..5f81d3b 100644 --- a/src/cli/release/mod.rs +++ b/src/cli/release/mod.rs @@ -45,6 +45,7 @@ pub fn execute(path: &str, embed: bool) -> Result { false, false, None, + None, true, )?; result.installed.clear(); diff --git a/src/cli/watchlist/mod.rs b/src/cli/watchlist/mod.rs index 7aec97e..32dc349 100644 --- a/src/cli/watchlist/mod.rs +++ b/src/cli/watchlist/mod.rs @@ -145,19 +145,28 @@ pub fn list(json: bool) -> Result { /// Adds a local path to the watchlist. pub fn add_path(path: &str, json: bool) -> Result { + if add_path_silent(path)? { + announce(json, &format!("watching: {path}")); + } else { + announce(json, &format!("already watched: {path}")); + } + Ok(0) +} + +/// Adds a path to the watchlist without printing. Returns whether the path +/// was newly added (false when it was already watched). +pub fn add_path_silent(path: &str) -> Result { let mut config = load_strict()?; if config .locations .iter() .any(|entry| matches!(entry, WatchEntry::Path(existing) if existing == path)) { - announce(json, &format!("already watched: {path}")); - return Ok(0); + return Ok(false); } config.locations.push(WatchEntry::Path(path.to_string())); sort_and_save(&mut config)?; - announce(json, &format!("watching: {path}")); - Ok(0) + Ok(true) } /// Adds a SHA-pinned remote repo. HTTPS-only, full 40-char lowercase-hex commit required. diff --git a/src/services/adr.rs b/src/services/adr.rs index b949cbd..5c626a8 100644 --- a/src/services/adr.rs +++ b/src/services/adr.rs @@ -65,6 +65,7 @@ pub(super) fn discover_adrs( state, source, summary: adr_summary(&raw), + local_path: decisions.join(&name).to_string_lossy().into_owned(), }); } } @@ -148,6 +149,7 @@ pub fn build_adr_artifact(adr: &Adr, local_repos: &HashMap) -> kind: "adr".to_string(), module: adr.repo.clone(), relative_path: adr.relative_path.clone(), + source_path: adr.relative_path.clone(), description: adr.title.clone(), content_preview: String::new(), content_body: content.body, @@ -162,6 +164,7 @@ pub fn build_adr_artifact(adr: &Adr, local_repos: &HashMap) -> module_tint: 0, companions: Vec::new(), variants: Vec::new(), + vcs: None, } } diff --git a/src/services/builders.rs b/src/services/builders.rs index c508e8a..7295c91 100644 --- a/src/services/builders.rs +++ b/src/services/builders.rs @@ -508,6 +508,9 @@ mod tests { source_uri: format!("https://example.com/{name}"), is_target: false, artifacts, + local_path: None, + vcs: None, + git_log: Vec::new(), } } diff --git a/src/services/discovery.rs b/src/services/discovery.rs index 1456597..c0a4c89 100644 --- a/src/services/discovery.rs +++ b/src/services/discovery.rs @@ -136,6 +136,9 @@ pub(super) fn scan_source_module(root: &Path) -> Option { source_uri, is_target: true, artifacts, + local_path: None, + vcs: None, + git_log: Vec::new(), }) } @@ -230,6 +233,7 @@ fn build_source_artifact( kind: kind.to_string(), module: String::new(), relative_path: relative_path.to_string(), + source_path: relative_path.to_string(), description, content_preview, content_body, @@ -244,6 +248,7 @@ fn build_source_artifact( module_tint: 0, companions: Vec::new(), variants: Vec::new(), + vcs: None, } } diff --git a/src/services/mod.rs b/src/services/mod.rs index 0a410f9..17dc437 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -15,6 +15,7 @@ mod references; mod sidecar; mod source; mod target; +mod vcs; pub use adr::build_adr_artifact; pub use discovery::discover_local_repos; @@ -22,6 +23,8 @@ pub use history::{ extract_frontmatter_field, git_log_for_artifact, read_source_adoption, read_source_sidecar, source_at_deploy, }; +pub use source::{parse_frontmatter, strip_frontmatter}; +pub use target::git_log_in_repo; use crate::error::{Error, ErrorKind}; use crate::provider::ContentKind; @@ -97,6 +100,9 @@ pub fn build_view( source_uri: String::new(), is_target: false, artifacts: Vec::new(), + local_path: None, + vcs: None, + git_log: Vec::new(), }); } @@ -106,16 +112,35 @@ pub fn build_view( } let provider_names: Vec = providers.iter().map(|(name, _)| name.clone()).collect(); + // Several modules can share one repo; scan each repo's VCS state once. + let mut repo_state: BTreeMap, Vec)> = + BTreeMap::new(); for (module_index, module) in modules.iter_mut().enumerate() { module.artifacts.sort_by(|a, b| a.name.cmp(&b.name)); let repo = local_repos.get(module.source_uri.trim_end_matches(".git")); + if let Some(repo) = repo + && !repo_state.contains_key(repo.as_path()) + { + repo_state.insert(repo.clone(), (vcs::repo_vcs(repo), vcs::repo_log(repo))); + } + let cached = repo.and_then(|repo| repo_state.get(repo.as_path())); + let repo_vcs = cached.and_then(|(state, _)| state.as_ref()); + module.local_path = repo.cloned(); + module.vcs = repo_vcs.map(vcs::RepoVcs::module_state); + module.git_log = cached.map(|(_, log)| log.clone()).unwrap_or_default(); let tint = module_index % 8; for artifact in &mut module.artifacts { artifact.module.clone_from(&module.name); artifact.module_tint = tint; + let vcs_path = if artifact.source_path.is_empty() { + artifact.relative_path.clone() + } else { + artifact.source_path.clone() + }; + artifact.vcs = repo_vcs.map(|state| state.state_for(&vcs_path)); let (broken, age) = artifact_staleness( repo, - &artifact.relative_path, + &vcs_path, &artifact.raw_source, artifact.latest_commit_date(), ); diff --git a/src/services/source.rs b/src/services/source.rs index 245f99f..5bfdec5 100644 --- a/src/services/source.rs +++ b/src/services/source.rs @@ -106,7 +106,7 @@ pub(super) fn read_source_content( } /// Parses flat frontmatter fields, preserving their source order for display. -pub(super) fn parse_frontmatter(content: &str) -> Vec<(String, String)> { +pub fn parse_frontmatter(content: &str) -> Vec<(String, String)> { let mut fields = Vec::new(); let Some(rest) = content.strip_prefix("---") else { return fields; @@ -150,7 +150,7 @@ pub(super) fn read_artifact_content(provider_path: &Path, relative_key: &str) -> ArtifactContent { description, body } } -pub(super) fn strip_frontmatter(content: &str) -> String { +pub fn strip_frontmatter(content: &str) -> String { let Some(rest) = content.strip_prefix("---") else { return content.to_string(); }; diff --git a/src/services/target.rs b/src/services/target.rs index 7bd362b..e1b690c 100644 --- a/src/services/target.rs +++ b/src/services/target.rs @@ -39,7 +39,7 @@ pub(super) fn sidecar_name_warning(relative_path: &str, sidecar_path: &Path) -> } } -pub(super) fn git_log_in_repo(repo: &Path, file_rel: &str) -> Vec { +pub fn git_log_in_repo(repo: &Path, file_rel: &str) -> Vec { let output = Command::new("git") .args(["log", "--follow", "-n", "5", GIT_LOG_FORMAT, "--", file_rel]) .current_dir(repo) @@ -106,6 +106,9 @@ pub(super) fn scan_target( source_uri: source, is_target: false, artifacts: Vec::new(), + local_path: None, + vcs: None, + git_log: Vec::new(), }); let provider_status = ProviderStatus { @@ -174,6 +177,7 @@ pub(super) fn build_deployed_artifact( kind: kind.to_string(), module: String::new(), relative_path: relative_key.to_string(), + source_path: source_path.unwrap_or_default().to_string(), description, content_preview, content_body, @@ -188,6 +192,7 @@ pub(super) fn build_deployed_artifact( module_tint: 0, companions: Vec::new(), variants: Vec::new(), + vcs: None, } } diff --git a/src/services/vcs.rs b/src/services/vcs.rs new file mode 100644 index 0000000..5cf1753 --- /dev/null +++ b/src/services/vcs.rs @@ -0,0 +1,287 @@ +//! Per-repo version-control state: branch, ahead/behind, dirty paths, and +//! jj colocation. One `git status` per repo; artifacts are matched against +//! the dirty set by path. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use super::history::{GIT_LOG_FORMAT, enrich_commits_with_entire, parse_git_log}; +use crate::view::{GitCommit, VcsState, WorktreeState}; + +pub(super) struct RepoVcs { + branch: String, + ahead: usize, + behind: usize, + jj_colocated: bool, + /// Module directory relative to the repo root — empty today (module dir + /// is the root), non-empty once modules live inside a monorepo. + prefix: PathBuf, + dirty: HashSet, + untracked: HashSet, +} + +impl RepoVcs { + pub(super) fn state_for(&self, relative_path: &str) -> VcsState { + let repo_relative = self + .prefix + .join(relative_path) + .to_string_lossy() + .into_owned(); + let worktree = if self.covers_untracked(&repo_relative) { + WorktreeState::Untracked + } else if self.dirty.contains(&repo_relative) { + WorktreeState::Modified + } else { + WorktreeState::Clean + }; + VcsState { + branch: self.branch.clone(), + worktree, + ahead: self.ahead, + behind: self.behind, + jj_colocated: self.jj_colocated, + } + } + + /// Repo-level state for the module row: modified when anything under the + /// module's prefix is dirty or untracked. + pub(super) fn module_state(&self) -> VcsState { + let touched = self.dirty.iter().chain(self.untracked.iter()).any(|path| { + self.prefix.as_os_str().is_empty() || Path::new(path).starts_with(&self.prefix) + }); + VcsState { + branch: self.branch.clone(), + worktree: if touched { + WorktreeState::Modified + } else { + WorktreeState::Clean + }, + ahead: self.ahead, + behind: self.behind, + jj_colocated: self.jj_colocated, + } + } + + /// Untracked directories appear in porcelain output as a single entry with + /// a trailing slash covering everything beneath them. + fn covers_untracked(&self, repo_relative: &str) -> bool { + self.untracked.contains(repo_relative) + || self + .untracked + .iter() + .any(|entry| entry.ends_with('/') && repo_relative.starts_with(entry.as_str())) + } +} + +pub(super) fn repo_vcs(module_dir: &Path) -> Option { + let root_raw = git_stdout(module_dir, &["rev-parse", "--show-toplevel"])?; + let root = std::fs::canonicalize(root_raw.trim()).ok()?; + let jj_colocated = root.join(".jj").is_dir(); + let branch = branch_label(module_dir, jj_colocated); + let (behind, ahead) = upstream_counts(module_dir, &branch); + let status = git_stdout(module_dir, &["status", "--porcelain", "-z"]).unwrap_or_default(); + let (dirty, untracked) = parse_status(&status); + let module_canonical = std::fs::canonicalize(module_dir).ok()?; + let prefix = module_canonical + .strip_prefix(&root) + .unwrap_or(Path::new("")) + .to_path_buf(); + Some(RepoVcs { + branch, + ahead, + behind, + jj_colocated, + prefix, + dirty, + untracked, + }) +} + +/// Jujutsu-colocated repos keep git HEAD detached, so `--abbrev-ref HEAD` +/// answers `HEAD` there. Prefer the jj bookmark on the working-copy parent, +/// then a branch pointing at HEAD, then the short sha. +fn branch_label(dir: &Path, jj_colocated: bool) -> String { + let named = git_stdout(dir, &["rev-parse", "--abbrev-ref", "HEAD"]) + .map(|out| out.trim().to_string()) + .unwrap_or_default(); + if !named.is_empty() && named != "HEAD" { + return named; + } + if jj_colocated { + let bookmark = command_stdout( + dir, + "jj", + &[ + "--ignore-working-copy", + "log", + "--no-graph", + "-r", + "heads(::@- & bookmarks())", + "-T", + "local_bookmarks.join(\",\") ++ \"\\n\"", + ], + ) + .and_then(|out| out.lines().next().map(str::to_string)) + .unwrap_or_default(); + if !bookmark.is_empty() { + return bookmark; + } + } + let pointing = git_stdout( + dir, + &[ + "for-each-ref", + "--points-at", + "HEAD", + "--format=%(refname:short)", + "refs/heads", + ], + ) + .and_then(|out| out.lines().next().map(str::to_string)) + .unwrap_or_default(); + if !pointing.is_empty() { + return pointing; + } + git_stdout(dir, &["rev-parse", "--short", "HEAD"]) + .map(|out| format!("detached {}", out.trim())) + .unwrap_or_default() +} + +/// Most recent commits across the whole repo, for the repository detail view. +pub(super) fn repo_log(repo: &Path) -> Vec { + let Some(raw) = git_stdout(repo, &["log", "-n", "8", GIT_LOG_FORMAT]) else { + return Vec::new(); + }; + let mut commits = parse_git_log(&raw); + enrich_commits_with_entire(repo, &mut commits); + commits +} + +fn git_stdout(dir: &Path, args: &[&str]) -> Option { + command_stdout(dir, "git", args) +} + +fn command_stdout(dir: &Path, program: &str, args: &[&str]) -> Option { + let output = Command::new(program) + .args(args) + .current_dir(dir) + .output() + .ok()?; + if !output.status.success() { + return None; + } + Some(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +/// Ahead/behind relative to the upstream. `@{upstream}` needs an attached +/// HEAD, which jj-colocated repos never have — fall back to the resolved +/// branch's configured upstream, then to `origin/`. +fn upstream_counts(dir: &Path, branch: &str) -> (usize, usize) { + for range in upstream_ranges(branch) { + let counts = git_stdout(dir, &["rev-list", "--left-right", "--count", &range]) + .and_then(|out| parse_counts(&out)); + if let Some(counts) = counts { + return counts; + } + } + (0, 0) +} + +/// The branch label may carry jj artifacts: several bookmarks joined by +/// commas, or a `??` conflicted-bookmark suffix. Use the first bookmark, +/// stripped, for the fallback ranges. +fn upstream_ranges(branch: &str) -> Vec { + let mut ranges = vec!["@{upstream}...HEAD".to_string()]; + let cleaned = branch + .split(',') + .next() + .unwrap_or_default() + .trim_end_matches('?'); + if !cleaned.is_empty() && !cleaned.starts_with("detached ") { + ranges.push(format!("{cleaned}@{{upstream}}...HEAD")); + ranges.push(format!("origin/{cleaned}...HEAD")); + } + ranges +} + +/// `git rev-list --left-right --count @{upstream}...HEAD` prints +/// `\t`: left side counts commits only on the upstream. +fn parse_counts(raw: &str) -> Option<(usize, usize)> { + let mut fields = raw.split_whitespace(); + let behind = fields.next()?.parse().ok()?; + let ahead = fields.next()?.parse().ok()?; + Some((behind, ahead)) +} + +/// Splits `git status --porcelain -z` output into (dirty, untracked) path +/// sets. NUL separation means paths arrive verbatim — no C-style quoting to +/// decode and no ` -> ` rename ambiguity. A rename or copy entry carries the +/// new path inline and its original path as the following NUL field, which is +/// consumed and dropped. +fn parse_status(raw: &str) -> (HashSet, HashSet) { + let mut dirty = HashSet::new(); + let mut untracked = HashSet::new(); + let mut fields = raw.split('\0'); + while let Some(entry) = fields.next() { + if entry.len() < 4 || !entry.is_char_boundary(3) { + continue; + } + let (code, path) = entry.split_at(3); + if code.starts_with("??") { + untracked.insert(path.to_string()); + continue; + } + dirty.insert(path.to_string()); + if code.contains('R') || code.contains('C') { + let _original_path = fields.next(); + } + } + (dirty, untracked) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_status_separates_dirty_and_untracked() { + let (dirty, untracked) = + parse_status(" M agents/Analyst.md\0?? skills/NewSkill/\0R rules/new.md\0old.md\0"); + assert!(dirty.contains("agents/Analyst.md")); + assert!(dirty.contains("rules/new.md")); + assert!(untracked.contains("skills/NewSkill/")); + assert!(!dirty.contains("old.md")); + } + + #[test] + fn parse_status_keeps_special_characters_verbatim() { + let (dirty, _) = parse_status(" M skills/Nový/SKILL.md\0 M skills/a -> b/SKILL.md\0"); + assert!(dirty.contains("skills/Nový/SKILL.md")); + assert!(dirty.contains("skills/a -> b/SKILL.md")); + } + + #[test] + fn untracked_directory_covers_children() { + let (dirty, untracked) = parse_status("?? skills/NewSkill/\0"); + let repo = RepoVcs { + branch: "main".to_string(), + ahead: 0, + behind: 0, + jj_colocated: false, + prefix: PathBuf::new(), + dirty, + untracked, + }; + let state = repo.state_for("skills/NewSkill/SKILL.md"); + assert_eq!(state.worktree, WorktreeState::Untracked); + let clean = repo.state_for("skills/OldSkill/SKILL.md"); + assert_eq!(clean.worktree, WorktreeState::Clean); + } + + #[test] + fn parse_counts_reads_behind_then_ahead() { + assert_eq!(parse_counts("1\t3\n"), Some((1, 3))); + assert_eq!(parse_counts("garbage"), None); + } +} diff --git a/src/tui/app.rs b/src/tui/app.rs index 3f7de17..21a81a8 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -7,38 +7,46 @@ use std::{ thread, }; -use crossterm::event::{KeyCode, KeyEvent}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::{ Frame, - layout::{Constraint, Direction, Layout, Rect}, + layout::{Constraint, Direction, Layout, Position, Rect}, style::{Color, Modifier, Style}, text::{Line, Span, Text}, widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap}, }; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use commands::{ services::{ self, builders, files::{self, FileSections}, }, - view::{Adr, ArtifactView, DashboardView, ModuleView, ProvenanceArtifact, StatusSummary}, + view::{ + Adr, ArtifactView, Companion, DashboardView, ModuleView, ProvenanceArtifact, StatusSummary, + VcsState, WorktreeState, + }, }; use crate::cli::{config, watchlist}; use super::components::{ palette::{Palette, PaletteCommand}, - preview::ArtifactPreview, + preview::{ArtifactPreview, wrapped_rows}, }; use super::rich; +use super::word_wrap::expand_gutter_wrapped; const SECTION_COUNT: usize = 13; -const DETAIL_TAB_COUNT: usize = 7; +const DETAIL_TAB_COUNT: usize = 6; const LEFT_MIN_WIDTH: u16 = 14; const LEFT_MAX_WIDTH: u16 = 20; const MIDDLE_MIN_WIDTH: u16 = 24; const MIDDLE_MAX_WIDTH: u16 = 40; const MIN_DETAIL_WIDTH: u16 = 20; +/// Columns occupied by the code gutter: comment marker (2) plus a +/// right-aligned line number (4) plus one space. +const CODE_GUTTER: usize = 7; pub const KEYBINDINGS: &[(&str, &[(&str, &str)])] = &[ ( @@ -57,33 +65,27 @@ pub const KEYBINDINGS: &[(&str, &[(&str, &str)])] = &[ ( "Sections", &[ - ("1", "overview"), - ("2", "skills"), - ("3", "agents"), - ("4", "rules"), - ("5", "repositories"), - ("6", "ADRs"), - ("7", "provenance"), - ("8", "variants"), - ("9", "search"), - ("t", "settings"), - ("h", "hooks"), - ("c", "config"), - ("m", "schemas"), + ("j/k", "move between sections"), + ("o s a r", "overview, skills, agents, rules"), + ("R d p v", "repositories, ADRs, provenance, variants"), + ("f t h c m", "search, settings, hooks, config, schemas"), ], ), ( "Actions", &[ - ("/", "search"), + ("/", "filter the focused list (Search: edit query)"), (":", "palette"), ("r", "refresh"), ("y", "copy install snippet or path"), - ("d", "diff tab"), - ("c", "code tab"), - ("p", "preview tab"), - ("m", "comment current code line"), + ("Tab", "next detail tab"), + ("p c d v f i", "detail tabs (outside Sections focus)"), + ("!", "toggle problems-only"), + ("m", "comment line (from any detail tab)"), ("Y", "copy tuicr comments"), + ("o/O", "open gitui / jjui on repository"), + ("D", "deploy module to a target"), + ("L", "launch harness session in repository"), ], ), ("Global", &[("?", "help"), ("F1", "help"), ("q", "quit")]), @@ -171,17 +173,37 @@ impl Section { } } + /// The key that reaches this section from the Sections column, shown as + /// the row prefix; sections without one show a plain label. + fn shortcut_label(self) -> &'static str { + match self { + Self::Overview => "o", + Self::Skills => "s", + Self::Agents => "a", + Self::Rules => "r", + Self::Repositories => "R", + Self::Adrs => "d", + Self::Provenance => "p", + Self::Variants => "v", + Self::Search => "f", + Self::Settings => "t", + Self::Hooks => "h", + Self::Config => "c", + Self::Schemas => "m", + } + } + fn from_shortcut(character: char) -> Option { match character { - '1' => Some(Self::Overview), - '2' => Some(Self::Skills), - '3' => Some(Self::Agents), - '4' => Some(Self::Rules), - '5' => Some(Self::Repositories), - '6' => Some(Self::Adrs), - '7' => Some(Self::Provenance), - '8' => Some(Self::Variants), - '9' => Some(Self::Search), + 'o' => Some(Self::Overview), + 's' => Some(Self::Skills), + 'a' => Some(Self::Agents), + 'r' => Some(Self::Rules), + 'R' => Some(Self::Repositories), + 'd' => Some(Self::Adrs), + 'p' => Some(Self::Provenance), + 'v' => Some(Self::Variants), + 'f' => Some(Self::Search), 't' | 'T' => Some(Self::Settings), 'h' | 'H' => Some(Self::Hooks), 'c' | 'C' => Some(Self::Config), @@ -199,21 +221,19 @@ pub enum DetailTab { Provenance = 3, Frontmatter = 4, History = 5, - Companions = 6, } impl DetailTab { - const ALL: [Self; DETAIL_TAB_COUNT] = [ + pub(super) const ALL: [Self; DETAIL_TAB_COUNT] = [ Self::Preview, Self::Code, Self::Diff, Self::Provenance, Self::Frontmatter, Self::History, - Self::Companions, ]; - fn label(self) -> &'static str { + pub(super) fn label(self) -> &'static str { match self { Self::Preview => "Preview", Self::Code => "Code", @@ -221,7 +241,6 @@ impl DetailTab { Self::Provenance => "Provenance", Self::Frontmatter => "Frontmatter", Self::History => "History", - Self::Companions => "Companions", } } @@ -265,6 +284,24 @@ enum HelpState { enum ListTarget { None, Overview, + /// The Overview Nested/Matrix mode row: activating it toggles the mode. + OverviewMode, + /// A status count on the Overview: jumps to Search filtered to it. + StatusJump(String), + /// A kind group on the Overview: jumps to that kind's section. + KindJump(String), + /// A module under a kind on the Overview: jumps to the kind's section + /// with the in-panel filter set to the module. + ModuleJump { + kind: String, + module: String, + }, + /// A skill companion file, shown as a child row under its parent skill. + Companion { + module: String, + parent: String, + name: String, + }, Artifact { module: String, kind: String, @@ -347,11 +384,66 @@ pub struct MillerColumnWidths { pub middle: u16, } +/// A command to run with the real terminal while the TUI is suspended. +#[derive(Debug, Clone)] +pub struct ExternalCommand { + pub program: String, + pub args: Vec, + pub directory: PathBuf, +} + +/// Modal target picker for deploying a module or a single artifact. +#[derive(Debug, Clone)] +struct DeployPicker { + /// What deploys: `module forge-core` or `skill HomebrewToolkit`. + scope_label: String, + source: PathBuf, + /// `--only` prefix when deploying a single artifact. + only: Option, + options: Vec<(String, PathBuf)>, + selected: usize, + /// Path being typed when the "add target" row is active. + input: Option, +} + +/// Modal harness picker for launching a session in a repo. +#[derive(Debug, Clone)] +struct LaunchPicker { + module_name: String, + directory: PathBuf, + options: Vec<(String, String)>, + selected: usize, +} + +/// Screen rectangles captured during render so mouse events can be +/// hit-tested against what is actually on screen. +#[derive(Debug, Clone, Copy, Default)] +struct MouseRegions { + sections: Rect, + list: Rect, + detail: Rect, + tabs: Rect, + /// The detail body below any tab bar, for row-accurate link clicks. + detail_body: Rect, +} + +/// Rendered lines for the current detail view, rebuilt only when the target, +/// tab, or pane width changes — per-frame rebuilds are what made the detail +/// pane feel slow. #[derive(Debug, Clone, PartialEq, Eq)] -struct PreviewCache { - path: String, +struct DetailCache { + key: String, width: u16, lines: Vec>, + /// Lines already wrapped at the pane width (glow output): render a + /// scrolled window without Paragraph wrap, which would break tables. + windowed: bool, + /// Row offsets of diff hunk headers, for [ and ] navigation. + hunks: Vec, + /// Per-row source line (new file) in the Diff tab, for per-line comments. + line_map: Vec>, + /// Per-row browser link (commit URLs) — clicking the row opens it. + links: Vec>, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -396,12 +488,14 @@ struct LineComment { #[derive(Debug, Clone, PartialEq, Eq)] struct CommentPrompt { + module: String, path: String, line_number: usize, kind: CommentKind, text: String, } +#[allow(clippy::struct_excessive_bools)] pub struct App { root: PathBuf, providers: Vec<(String, String)>, @@ -417,9 +511,9 @@ pub struct App { rows_dirty: bool, #[cfg(test)] row_build_count: usize, - preview_cache: Option, + preview_cache: Option, code_cache: Option, - comments: BTreeMap<(String, usize), LineComment>, + comments: BTreeMap<(String, String, usize), LineComment>, comment_prompt: Option, #[cfg(test)] preview_cache_build_count: usize, @@ -436,6 +530,37 @@ pub struct App { preview: Option, help_state: HelpState, palette: Palette, + mouse_regions: MouseRegions, + /// External command queued to run with the real terminal (gitui/jjui, + /// deploys, harness launches); the event loop suspends, runs, resumes. + pending_external: Option, + /// Target picker for deploying a module: options and selection. + deploy_picker: Option, + /// Harness picker for launching a session in a repo. + launch_picker: Option, + /// First visible row of the list column (viewport scroll offset). + list_offset: usize, + /// Selection seen at the last render, to detect selection movement. + list_last_selected: usize, + /// Second-press confirmation state for quitting with unsaved comments. + quit_armed: bool, + /// Line cursor for the Code tab, decoupled from the viewport: keys move + /// it (viewport follows), the wheel scrolls without touching it. + detail_cursor: usize, + /// Detail body height at the last render, for cursor-follow and paging. + detail_viewport: usize, + /// Synthesized artifact for the selected ADR or companion, keyed by a + /// stable identity so tab switches do not re-read files or re-run git. + synthesized: Option<(String, ArtifactView)>, + /// Whether keystrokes in the Search section edit the query (explicit + /// input mode) or navigate the result list. + search_typing: bool, + /// In-panel filter narrowing the focused list; empty when off. + list_filter: String, + /// Whether keystrokes edit the in-panel filter. + list_filter_typing: bool, + /// Show only rows whose status needs attention (modified/stale/new). + problems_only: bool, } impl App { @@ -503,13 +628,44 @@ impl App { preview: None, help_state: HelpState::Closed, palette: Palette::new(), + mouse_regions: MouseRegions::default(), + pending_external: None, + deploy_picker: None, + launch_picker: None, + list_offset: 0, + list_last_selected: 0, + quit_armed: false, + list_filter: String::new(), + list_filter_typing: false, + problems_only: false, + detail_cursor: 0, + detail_viewport: 1, + synthesized: None, + search_typing: false, } } pub fn refresh(&mut self) { + if self.scan_state == ScanState::Loading { + self.toast = Some("scan already running".to_string()); + return; + } + self.start_scan(); + } + + /// Restarts the scan even when one is in flight, superseding its result — + /// used after an external tool may have changed the repos. + pub fn force_refresh(&mut self) { self.start_scan(); } + /// Whether a background scan is still in flight (used by snapshot mode to + /// block until real data is available before rendering a frame). + #[must_use] + pub fn scan_pending(&self) -> bool { + self.scan_receiver.is_some() + } + pub fn poll_scan(&mut self) { let Some(receiver) = &self.scan_receiver else { return; @@ -523,8 +679,11 @@ impl App { self.scan_state = ScanState::Idle; self.scan_receiver = None; self.toast = Some("scan complete".to_string()); + let previous_target = self.selected_target(); self.invalidate_rows(); self.invalidate_detail_caches(); + self.refresh_open_preview(); + self.restore_selection(previous_target); self.clamp_list_selection(); } Ok(Err(error)) => { @@ -576,8 +735,35 @@ impl App { } pub fn render(&mut self, frame: &mut Frame<'_>) { - if let Some(preview) = self.preview.as_mut() { - preview.render(frame, frame.area()); + if self.preview.is_some() { + let area = frame.area(); + let inner_width = area.width.saturating_sub(2).max(1); + let tab = self.detail_tab; + let needs_rebuild = self + .preview + .as_ref() + .is_some_and(|preview| preview.needs_rebuild(tab, inner_width)); + if needs_rebuild { + let artifact = self + .preview + .as_ref() + .map(|preview| preview.artifact().clone()) + .expect("preview is open"); + let (lines, windowed) = { + let module = self + .view + .modules + .iter() + .find(|module| module.name == artifact.module); + self.build_detail_lines(module, &artifact, tab, inner_width) + }; + if let Some(preview) = self.preview.as_mut() { + preview.set_lines(tab, inner_width, lines, windowed); + } + } + if let Some(preview) = self.preview.as_mut() { + preview.render(frame, area); + } return; } @@ -601,11 +787,22 @@ impl App { Constraint::Min(0), ]) .split(layout[1]); + self.mouse_regions.sections = columns[0]; + self.mouse_regions.list = columns[1]; + self.mouse_regions.detail = columns[2]; + self.mouse_regions.tabs = Rect::default(); + self.mouse_regions.detail_body = Rect::default(); self.render_sections(frame, columns[0]); self.render_list(frame, columns[1]); self.render_detail(frame, columns[2]); self.render_footer(frame, layout[2]); + if let Some(picker) = &self.deploy_picker { + render_deploy_picker(frame, frame.area(), picker); + } + if let Some(picker) = &self.launch_picker { + render_launch_picker(frame, frame.area(), picker); + } if self.help_state == HelpState::Open { render_help(frame, frame.area()); } @@ -618,8 +815,13 @@ impl App { "ready" }; let summary = &self.view.summary; + let comments = if self.comments.is_empty() { + String::new() + } else { + format!(" | ✎ {} comments (Y copies)", self.comments.len()) + }; let text = format!( - " forge tui | {scan} | ok {} stale {} modified {} new {} | {} modules", + " forge tui | {scan} | ok {} stale {} modified {} new {} | {} modules{comments}", summary.unchanged, summary.stale, summary.modified, @@ -645,8 +847,10 @@ impl App { self.palette.display_text(self.palette_error.as_deref()) } else if let Some(toast) = &self.toast { format!(" {toast}") + } else if let Some((current, total)) = self.hunk_position() { + format!("hunk {current}/{total} · ] next hunk · [ previous hunk · j/k scroll") } else { - hint_row() + hint_row(self.focused) }; frame.render_widget( Paragraph::new(text).style(Style::default().fg(Color::DarkGray)), @@ -663,7 +867,8 @@ impl App { .iter() .enumerate() .map(|(index, section)| { - let prefix = format!("{} ", index + 1); + let _ = index; + let prefix = format!("{} ", section.shortcut_label()); let style = if *section == self.section { selected_style(self.focused == ColumnFocus::Sections) } else { @@ -678,26 +883,68 @@ impl App { frame.render_widget(List::new(items), inner); } - fn render_list(&self, frame: &mut Frame<'_>, area: Rect) { - let title = format!(" {} ", self.section.label()); + fn render_list(&mut self, frame: &mut Frame<'_>, area: Rect) { + let mode = if self.list_filter_typing { + format!(" · /{}▌", self.list_filter) + } else if !self.list_filter.is_empty() { + format!(" · /{}", self.list_filter) + } else if self.problems_only { + " · [!]".to_string() + } else { + let selectable = self + .cached_rows + .iter() + .filter(|row| row.is_selectable()) + .count(); + if selectable == 0 { + String::new() + } else { + let position = self + .cached_rows + .iter() + .take(self.selected_list_index(&self.cached_rows) + 1) + .filter(|row| row.is_selectable()) + .count(); + format!(" · {position}/{selectable}") + } + }; + let title = format!(" {}{mode} ", self.section.label()); let block = column_block(&title, self.focused == ColumnFocus::List); let inner = block.inner(area); frame.render_widget(block, area); - let rows = self.cached_rows(); - if self.scan_state == ScanState::Loading && rows.is_empty() { + if self.scan_state == ScanState::Loading && self.cached_rows.is_empty() { frame.render_widget( Paragraph::new("Scanning modules...").style(Style::default().fg(Color::Gray)), inner, ); return; } - let selected = self.selected_list_index(rows); + let viewport = usize::from(inner.height.max(1)); + let selected = self.selected_list_index(&self.cached_rows); + // The viewport follows selection changes (keyboard/click), while wheel + // scrolling moves the offset alone — passive gestures never drag the + // selection, and moving the selection always brings it back on screen. + if selected != self.list_last_selected { + if selected < self.list_offset { + self.list_offset = selected; + } else if selected + 1 > self.list_offset + viewport { + self.list_offset = selected + 1 - viewport; + } + self.list_last_selected = selected; + } + self.list_offset = self + .list_offset + .min(self.cached_rows.len().saturating_sub(viewport)); + let offset = self.list_offset; + let rows = &self.cached_rows; let items: Vec> = if rows.is_empty() { vec![ListItem::new("no rows")] } else { rows.iter() .enumerate() + .skip(offset) + .take(viewport) .map(|(index, row)| { if row.header { return ListItem::new(Line::from(Span::styled( @@ -712,12 +959,30 @@ impl App { } else { Style::default() }; - ListItem::new(Line::from(vec![ + let mut spans = vec![ Span::styled(status_dot(row.status), status_style(row.status)), Span::raw(" "), Span::styled(row.label.clone(), base), - Span::styled(format!(" {}", row.detail), base.fg(Color::DarkGray)), - ])) + ]; + // The detail (owning module, qualifier) is a dim + // right-aligned column on every row; when tight it + // truncates from the left, never the label. + if !row.detail.is_empty() { + let used = 2 + UnicodeWidthStr::width(row.label.as_str()); + let room = usize::from(inner.width).saturating_sub(used); + if room >= 4 { + let detail_width = UnicodeWidthStr::width(row.detail.as_str()); + let (text, shown_width) = if detail_width < room { + (row.detail.clone(), detail_width) + } else { + truncate_left_to_width(&row.detail, room.saturating_sub(2)) + }; + let pad = room.saturating_sub(shown_width); + spans.push(Span::raw(" ".repeat(pad))); + spans.push(Span::styled(text, base.fg(Color::DarkGray))); + } + } + ListItem::new(Line::from(spans)) }) .collect() }; @@ -725,7 +990,23 @@ impl App { } fn render_detail(&mut self, frame: &mut Frame<'_>, area: Rect) { - let block = column_block(" Detail ", self.focused == ColumnFocus::Detail); + let position = match self.detail_tab { + DetailTab::Code => self + .code_cache + .as_ref() + .map(|cache| (self.detail_cursor + 1, cache.lines.len())), + _ => self + .preview_cache + .as_ref() + .map(|cache| (usize::from(self.detail_scroll) + 1, cache.lines.len())), + }; + let title = match position { + Some((current, total)) if total > 0 => { + format!(" Detail · {}/{total} ", current.min(total)) + } + _ => " Detail ".to_string(), + }; + let block = column_block(&title, self.focused == ColumnFocus::Detail); let inner = block.inner(area); frame.render_widget(block, area); @@ -744,18 +1025,25 @@ impl App { } } Some(ListTarget::Adr { repo, id }) => { - if let Some(adr) = self.find_adr(&repo, &id) { - render_adr_detail(frame, inner, adr); + if let Some(adr) = self.find_adr(&repo, &id).cloned() { + let identity = format!("adr:{}", adr.local_path); + let module_name = adr.repo.clone(); + self.render_synthesized_detail(frame, inner, &identity, &module_name, |app| { + app.build_adr_artifact_view(&adr) + }); } else { frame.render_widget(Paragraph::new("ADR not found"), inner); } } + Some(ListTarget::Companion { + module, + parent, + name, + }) => { + self.render_companion_detail(frame, inner, &module, &parent, &name); + } Some(ListTarget::Module(name)) => { - if let Some(module) = self.view.modules.iter().find(|module| module.name == name) { - render_module_detail(frame, inner, module); - } else { - frame.render_widget(Paragraph::new("repository not found"), inner); - } + self.render_module_rich(frame, inner, &name); } Some(ListTarget::Variant { module, @@ -808,25 +1096,362 @@ impl App { .direction(Direction::Vertical) .constraints([Constraint::Length(2), Constraint::Min(1)]) .split(area); + self.mouse_regions.tabs = chunks[0]; + self.mouse_regions.detail_body = chunks[1]; self.render_tabs(frame, chunks[0]); self.prepare_artifact_detail_cache(module_index, artifact_index, chunks[1].width); - let module = &self.view.modules[module_index]; - let artifact = &module.artifacts[artifact_index]; - let lines = match self.detail_tab { - DetailTab::Preview => self.preview_cache_lines(), - DetailTab::Code => self.code_cache_lines(artifact), - DetailTab::Diff => diff_lines(artifact), - DetailTab::Provenance => self.provenance_lines(module, artifact), - DetailTab::Frontmatter => frontmatter_lines(artifact), - DetailTab::History => history_lines(artifact), - DetailTab::Companions => companion_lines(artifact), + if self.detail_tab == DetailTab::Code { + let viewport = usize::from(chunks[1].height.max(1)); + self.detail_viewport = viewport; + // The cursor is the top visible line, so every line must be able + // to reach the top — clamp to the last line, not the last page. + let max_scroll = self + .code_cache + .as_ref() + .map_or(0, |cache| cache.lines.len()) + .saturating_sub(1); + self.detail_scroll = self + .detail_scroll + .min(u16::try_from(max_scroll).unwrap_or(u16::MAX)); + let artifact = &self.view.modules[module_index].artifacts[artifact_index]; + let lines = self.code_window(artifact, viewport); + // Pre-expand long lines so continuation rows align after the + // line-number gutter with a ↪ marker instead of sliding under it. + let mut rows = expand_gutter_wrapped(lines, CODE_GUTTER, usize::from(chunks[1].width)); + rows.truncate(viewport); + frame.render_widget(Paragraph::new(Text::from(rows)), chunks[1]); + } else { + let expected_key = { + let module = &self.view.modules[module_index]; + let artifact = &module.artifacts[artifact_index]; + detail_cache_key(self.detail_tab, &module.name, &artifact.relative_path) + }; + // A deferred rebuild (input still queued) leaves the previous + // artifact's lines in the cache; render a placeholder rather than + // content that belongs to another selection. + if self + .preview_cache + .as_ref() + .is_some_and(|cache| cache.key != expected_key) + { + frame.render_widget( + Paragraph::new("rendering…").style(Style::default().fg(Color::DarkGray)), + chunks[1], + ); + } else { + self.render_cached_detail(frame, chunks[1]); + } + } + } + + /// Full ADR document rendered through the markdown pipeline, replacing the + /// one-paragraph summary that used to cut the body off. + /// Repository detail: header, VCS state, recent commits, and the repo + /// README rendered through glow — cached like every other detail view. + fn render_module_rich(&mut self, frame: &mut Frame<'_>, area: Rect, name: &str) { + let Some(module_index) = self + .view + .modules + .iter() + .position(|module| module.name == name) + else { + frame.render_widget(Paragraph::new("repository not found"), area); + return; }; - frame.render_widget( - Paragraph::new(Text::from(lines)) - .wrap(Wrap { trim: false }) - .scroll((self.detail_scroll, 0)), - chunks[1], - ); + self.mouse_regions.detail_body = area; + let cache_width = area.width.max(1); + let key = format!("Module:{name}"); + let needs_build = self + .preview_cache + .as_ref() + .is_none_or(|cache| cache.key != key || cache.width != cache_width); + if needs_build { + if self.preview_cache.is_some() && input_pending() { + frame.render_widget( + Paragraph::new("rendering…").style(Style::default().fg(Color::DarkGray)), + area, + ); + return; + } + if self + .preview_cache + .as_ref() + .is_some_and(|cache| cache.key != key) + { + self.detail_scroll = 0; + } + let module = &self.view.modules[module_index]; + let mut lines = module_header_lines(module); + lines.extend(jj_log_lines(module)); + if let Some(readme) = module + .local_path + .as_ref() + .and_then(|path| std::fs::read_to_string(path.join("README.md")).ok()) + { + lines.push(Line::from(Span::styled( + "─".repeat(usize::from(cache_width)), + Style::default().fg(Color::DarkGray), + ))); + match rich::render_markdown_with_glow(&readme, cache_width) { + Some(rendered) => lines.extend(rendered), + None => lines.extend(readme.lines().map(|line| Line::from(line.to_string()))), + } + } + let lines = expand_gutter_wrapped(lines, 2, usize::from(cache_width)); + let row_links = commit_links(&lines, &module.source_uri); + self.preview_cache = Some(DetailCache { + key, + width: cache_width, + lines, + windowed: true, + hunks: Vec::new(), + line_map: Vec::new(), + links: row_links, + }); + } + self.render_cached_detail(frame, area); + } + + /// Renders a synthesized artifact (ADR, companion) through the same + /// tabbed detail pipeline as scanned artifacts: one artifact view + /// everywhere. + fn render_synthesized_detail( + &mut self, + frame: &mut Frame<'_>, + area: Rect, + identity: &str, + module_name: &str, + build: impl FnOnce(&Self) -> ArtifactView, + ) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(2), Constraint::Min(1)]) + .split(area); + self.mouse_regions.tabs = chunks[0]; + self.mouse_regions.detail_body = chunks[1]; + self.render_tabs(frame, chunks[0]); + let cache_width = chunks[1].width.max(1); + let key = detail_cache_key(self.detail_tab, module_name, identity); + let needs_build = self + .preview_cache + .as_ref() + .is_none_or(|cache| cache.key != key || cache.width != cache_width); + if needs_build { + if self.preview_cache.is_some() && input_pending() { + frame.render_widget( + Paragraph::new("rendering…").style(Style::default().fg(Color::DarkGray)), + chunks[1], + ); + return; + } + if self + .preview_cache + .as_ref() + .is_some_and(|cache| cache.key != key) + { + self.detail_scroll = 0; + } + if self + .synthesized + .as_ref() + .is_none_or(|(cached, _)| cached != identity) + { + let artifact = build(self); + self.synthesized = Some((identity.to_string(), artifact)); + } + let (lines, windowed) = { + let (_, artifact) = self.synthesized.as_ref().expect("synthesized just set"); + let module = self + .view + .modules + .iter() + .find(|module| module.name == module_name); + self.build_detail_lines(module, artifact, self.detail_tab, cache_width) + }; + let hunks = hunk_offsets(&lines); + let line_map = if self.detail_tab == DetailTab::Diff { + diff_line_map(&lines) + } else { + Vec::new() + }; + let row_links = if self.detail_tab == DetailTab::History { + let web = self + .view + .modules + .iter() + .find(|module| module.name == module_name) + .map(|module| module.source_uri.clone()) + .unwrap_or_default(); + commit_links(&lines, &web) + } else { + Vec::new() + }; + self.preview_cache = Some(DetailCache { + key, + width: cache_width, + lines, + windowed, + hunks, + line_map, + links: row_links, + }); + } + self.render_cached_detail(frame, chunks[1]); + } + + fn render_companion_detail( + &mut self, + frame: &mut Frame<'_>, + area: Rect, + module: &str, + parent: &str, + name: &str, + ) { + let found = self + .view + .modules + .iter() + .find(|candidate| candidate.name == module) + .and_then(|candidate| { + candidate + .artifacts + .iter() + .find(|artifact| artifact.name == parent) + }) + .and_then(|artifact| { + artifact + .companions + .iter() + .find(|companion| companion.name == name) + }) + .cloned(); + if let Some(companion) = found { + let identity = format!("companion:{module}:{parent}:{name}"); + self.render_synthesized_detail(frame, area, &identity, module, |app| { + app.build_companion_artifact_view(module, parent, &companion) + }); + } else { + frame.render_widget(Paragraph::new("companion not found"), area); + } + } + + /// One artifact view for an ADR: full raw source, stripped body, + /// frontmatter, per-file git history, and the module's VCS state. + fn build_adr_artifact_view(&self, adr: &Adr) -> ArtifactView { + let raw = std::fs::read_to_string(&adr.local_path) + .unwrap_or_else(|error| format!("could not read {}: {error}", adr.local_path)); + let body = services::strip_frontmatter(&raw); + let module = self + .view + .modules + .iter() + .find(|module| module.name == adr.repo); + let git_log = module + .and_then(|module| module.local_path.as_ref()) + .map(|repo| services::git_log_in_repo(repo, &adr.relative_path)) + .unwrap_or_default(); + ArtifactView { + name: format!("{} {}", adr.id, adr.title), + kind: "adr".to_string(), + module: adr.repo.clone(), + relative_path: adr.relative_path.clone(), + source_path: adr.relative_path.clone(), + description: format!("{} · {}", adr.state, adr.status), + metadata: services::parse_frontmatter(&raw), + content_body: body, + raw_source: raw, + git_log, + vcs: module.and_then(|module| module.vcs.clone()), + ..ArtifactView::default() + } + } + + /// One artifact view for a skill companion file. + fn build_companion_artifact_view( + &self, + module_name: &str, + parent: &str, + companion: &Companion, + ) -> ArtifactView { + let module = self + .view + .modules + .iter() + .find(|module| module.name == module_name); + let git_log = module + .and_then(|module| module.local_path.as_ref()) + .map(|repo| services::git_log_in_repo(repo, &companion.relative_path)) + .unwrap_or_default(); + ArtifactView { + name: format!("{parent}/{}", companion.name), + kind: "companion".to_string(), + module: module_name.to_string(), + relative_path: companion.relative_path.clone(), + source_path: companion.relative_path.clone(), + description: companion.description.clone(), + metadata: services::parse_frontmatter(&companion.raw_source), + content_body: companion.content_body.clone(), + raw_source: companion.raw_source.clone(), + git_log, + vcs: module.and_then(|module| module.vcs.clone()), + ..ArtifactView::default() + } + } + + /// Draws the current detail cache: windowed when the lines are already + /// wrapped at pane width (glow), wrap-and-scroll otherwise. + fn render_cached_detail(&mut self, frame: &mut Frame<'_>, area: Rect) { + let viewport = usize::from(area.height.max(1)); + self.detail_viewport = viewport; + let windowed = self + .preview_cache + .as_ref() + .is_some_and(|cache| cache.windowed); + if windowed { + let total = self + .preview_cache + .as_ref() + .map_or(0, |cache| cache.lines.len()); + let max_scroll = u16::try_from(total.saturating_sub(viewport)).unwrap_or(u16::MAX); + self.detail_scroll = self.detail_scroll.min(max_scroll); + let mut lines = self.preview_window(viewport); + if self.detail_tab == DetailTab::Diff { + let scroll = usize::from(self.detail_scroll); + if let Some(row) = self.detail_cursor.checked_sub(scroll) + && let Some(line) = lines.get_mut(row) + { + line.style = selected_style(self.focused == ColumnFocus::Detail); + } + } + frame.render_widget(Paragraph::new(Text::from(lines)), area); + } else { + let total = self + .preview_cache + .as_ref() + .map_or(0, |cache| wrapped_rows(&cache.lines, area.width.max(1))); + let max_scroll = u16::try_from(total.saturating_sub(viewport)).unwrap_or(u16::MAX); + self.detail_scroll = self.detail_scroll.min(max_scroll); + let lines = self.preview_cache_lines(); + frame.render_widget( + Paragraph::new(Text::from(lines)) + .wrap(Wrap { trim: false }) + .scroll((self.detail_scroll, 0)), + area, + ); + } + } + + fn preview_window(&self, viewport: usize) -> Vec> { + let scroll = usize::from(self.detail_scroll); + self.preview_cache.as_ref().map_or_else(Vec::new, |cache| { + cache + .lines + .iter() + .skip(scroll) + .take(viewport) + .cloned() + .collect() + }) } fn prepare_artifact_detail_cache( @@ -835,49 +1460,127 @@ impl App { artifact_index: usize, width: u16, ) { - match self.detail_tab { - DetailTab::Preview => { - let artifact = &self.view.modules[module_index].artifacts[artifact_index]; - let path = artifact.relative_path.clone(); - let cache_width = width.max(1); - let needs_build = self - .preview_cache - .as_ref() - .is_none_or(|cache| cache.path != path || cache.width != cache_width); - if needs_build { - let lines = preview_lines_for_width(artifact, cache_width); - self.preview_cache = Some(PreviewCache { - path, - width: cache_width, - lines, - }); - #[cfg(test)] - { - self.preview_cache_build_count += 1; - } + let cache_width = width.max(1); + if self.detail_tab == DetailTab::Code { + let module = &self.view.modules[module_index]; + let artifact = &module.artifacts[artifact_index]; + let key = format!("{}:{}", module.name, artifact.relative_path); + let needs_build = self + .code_cache + .as_ref() + .is_none_or(|cache| cache.path != key); + if needs_build { + self.detail_scroll = 0; + self.detail_cursor = 0; + let lines = rich::highlight_code(&artifact.relative_path, &artifact.raw_source); + self.code_cache = Some(CodeCache { path: key, lines }); + #[cfg(test)] + { + self.code_cache_build_count += 1; } } - DetailTab::Code => { - let artifact = &self.view.modules[module_index].artifacts[artifact_index]; - let path = artifact.relative_path.clone(); - let needs_build = self - .code_cache - .as_ref() - .is_none_or(|cache| cache.path != path); - if needs_build { - let lines = rich::highlight_code(&path, &artifact.raw_source); - self.code_cache = Some(CodeCache { path, lines }); - #[cfg(test)] - { - self.code_cache_build_count += 1; - } - } + return; + } + let key = { + let module = &self.view.modules[module_index]; + let artifact = &module.artifacts[artifact_index]; + detail_cache_key(self.detail_tab, &module.name, &artifact.relative_path) + }; + let needs_build = self + .preview_cache + .as_ref() + .is_none_or(|cache| cache.key != key || cache.width != cache_width); + if needs_build { + // Preview and Diff spawn subprocesses (glow, git). While keys are + // still queued — the user is holding j/k — keep the previous frame + // and rebuild once input drains, so browsing never stutters. + let expensive = matches!(self.detail_tab, DetailTab::Preview | DetailTab::Diff); + if expensive && self.preview_cache.is_some() && input_pending() { + return; + } + // A different target means new content: scrolling must restart at + // the top, or a short document renders as a blank pane. + if self + .preview_cache + .as_ref() + .is_some_and(|cache| cache.key != key) + { + self.detail_scroll = 0; + self.detail_cursor = 0; + } + let (lines, windowed) = { + let module = &self.view.modules[module_index]; + let artifact = &module.artifacts[artifact_index]; + self.build_detail_lines(Some(module), artifact, self.detail_tab, cache_width) + }; + let hunks = hunk_offsets(&lines); + let line_map = if self.detail_tab == DetailTab::Diff { + diff_line_map(&lines) + } else { + Vec::new() + }; + let row_links = if self.detail_tab == DetailTab::History { + let web = self.view.modules[module_index].source_uri.clone(); + commit_links(&lines, &web) + } else { + Vec::new() + }; + self.detail_cursor = self.detail_cursor.min(lines.len().saturating_sub(1)); + self.preview_cache = Some(DetailCache { + key, + width: cache_width, + lines, + windowed, + hunks, + line_map, + links: row_links, + }); + #[cfg(test)] + { + self.preview_cache_build_count += 1; } - DetailTab::Diff - | DetailTab::Provenance - | DetailTab::Frontmatter - | DetailTab::History - | DetailTab::Companions => {} + } + } + + /// Renders one detail tab to lines: the single pipeline behind the detail + /// pane and the fullscreen zoom, so both show the same rich content. + fn build_detail_lines( + &self, + module: Option<&ModuleView>, + artifact: &ArtifactView, + tab: DetailTab, + width: u16, + ) -> (Vec>, bool) { + match tab { + DetailTab::Preview => preview_lines_for_width(artifact, width), + DetailTab::Code => ( + expand_gutter_wrapped( + rich::highlight_code(&artifact.relative_path, &artifact.raw_source), + CODE_GUTTER, + usize::from(width), + ), + true, + ), + DetailTab::Diff => ( + expand_gutter_wrapped(diff_lines(module, artifact, width), 10, usize::from(width)), + true, + ), + DetailTab::Provenance => ( + expand_gutter_wrapped( + module.map_or_else( + || vec![Line::from("module not found")], + |module| self.provenance_lines(module, artifact), + ), + 2, + usize::from(width), + ), + true, + ), + DetailTab::Frontmatter => (frontmatter_lines(artifact, width), true), + DetailTab::History => ( + expand_gutter_wrapped(history_lines(artifact), 2, usize::from(width)), + true, + ), } } @@ -887,18 +1590,24 @@ impl App { .map_or_else(Vec::new, |cache| cache.lines.clone()) } - fn code_cache_lines(&self, artifact: &ArtifactView) -> Vec> { + fn code_window(&self, artifact: &ArtifactView, viewport: usize) -> Vec> { + let scroll = usize::from(self.detail_scroll); let current_line = self.current_code_line(artifact); + let module = &artifact.module; let path = &artifact.relative_path; self.code_cache.as_ref().map_or_else(Vec::new, |cache| { cache .lines .iter() - .cloned() .enumerate() - .map(|(index, mut line)| { + .skip(scroll) + .take(viewport) + .map(|(index, cached_line)| { + let mut line = cached_line.clone(); let line_number = index + 1; - let has_comment = self.comments.contains_key(&(path.clone(), line_number)); + let has_comment = + self.comments + .contains_key(&(module.clone(), path.clone(), line_number)); if let Some(marker) = line.spans.first_mut() { *marker = Span::styled( if has_comment { "◆ " } else { " " }, @@ -1005,7 +1714,7 @@ impl App { Line::from(format!("qualifier: {qualifier}")), Line::from(""), ]; - if let Some((_, artifact)) = self.find_artifact(module, kind, name) + if let Some((module_view, artifact)) = self.find_artifact(module, kind, name) && let Some(variant) = artifact .variants .iter() @@ -1014,9 +1723,19 @@ impl App { lines.push(Line::from(format!("merge mode: {}", variant.mode))); lines.push(Line::from(format!("path: {}", variant.relative_path))); lines.push(Line::from("")); - lines.push(Line::from( - "effective merge preview is deferred to the dashboard route", - )); + match module_view + .local_path + .as_ref() + .map(|repo| std::fs::read_to_string(repo.join(&variant.relative_path))) + { + Some(Ok(body)) => { + lines.extend(rich::highlight_code(&variant.relative_path, &body)); + } + Some(Err(error)) => { + lines.push(Line::from(format!("could not read variant: {error}"))); + } + None => lines.push(Line::from("no local repo — variant body unavailable")), + } } frame.render_widget( Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }), @@ -1025,9 +1744,30 @@ impl App { } pub fn request_quit(&mut self) { + if !self.comments.is_empty() && !self.quit_armed { + self.quit_armed = true; + self.toast = Some(format!( + "{} unsaved comments — press q again to quit (Y copies them first)", + self.comments.len() + )); + return; + } self.run_state = RunState::Quit; } + pub fn disarm_quit(&mut self) { + self.quit_armed = false; + } + + /// Esc walks focus back toward Sections and quits only from there — + /// backing out of a pane must never kill the session. + pub fn escape(&mut self) { + match self.focused { + ColumnFocus::Detail | ColumnFocus::List => self.focus_previous(), + ColumnFocus::Sections => self.request_quit(), + } + } + #[must_use] pub fn should_quit(&self) -> bool { self.run_state == RunState::Quit @@ -1161,11 +1901,94 @@ impl App { }; } + /// One navigation step in the detail pane: moves the Code cursor when the + /// Code tab is active, otherwise scrolls the viewport. + fn detail_step(&mut self, delta: isize) { + if matches!(self.detail_tab, DetailTab::Code | DetailTab::Diff) { + self.move_detail_cursor(delta); + } else if delta.is_negative() { + self.detail_scroll = self + .detail_scroll + .saturating_sub(u16::try_from(-delta).unwrap_or(0)); + } else { + self.detail_scroll = self + .detail_scroll + .saturating_add(u16::try_from(delta).unwrap_or(0)); + } + } + + /// Jumps the diff viewport to the next or previous hunk header. + fn jump_hunk(&mut self, forward: bool) { + let Some(cache) = self.preview_cache.as_ref() else { + return; + }; + let current = usize::from(self.detail_scroll); + let target = if forward { + cache.hunks.iter().find(|&&offset| offset > current) + } else { + cache.hunks.iter().rev().find(|&&offset| offset < current) + }; + if let Some(&offset) = target { + self.detail_scroll = u16::try_from(offset).unwrap_or(u16::MAX); + self.detail_cursor = offset; + } + } + + /// (current hunk, total hunks) for the footer while the Diff tab scrolls. + fn hunk_position(&self) -> Option<(usize, usize)> { + let cache = self.preview_cache.as_ref()?; + if self.detail_tab != DetailTab::Diff || cache.hunks.is_empty() { + return None; + } + let current = usize::from(self.detail_scroll); + let index = cache + .hunks + .iter() + .take_while(|&&offset| offset <= current) + .count() + .max(1); + Some((index, cache.hunks.len())) + } + + fn toggle_overview_mode(&mut self) { + self.overview_mode = match self.overview_mode { + OverviewMode::Nested => OverviewMode::Matrix, + OverviewMode::Matrix => OverviewMode::Nested, + }; + self.invalidate_rows(); + } + pub fn drill_or_expand(&mut self) { self.ensure_rows(); match self.focused { ColumnFocus::Sections => self.focused = ColumnFocus::List, ColumnFocus::List => { + match self.selected_target() { + Some(ListTarget::OverviewMode) => { + self.toggle_overview_mode(); + return; + } + Some(ListTarget::StatusJump(status)) => { + self.search = builders::SearchFilters::empty(); + self.search.status = status; + self.set_section(Section::Search); + return; + } + Some(ListTarget::KindJump(kind)) => { + if let Some(section) = Section::from_name(&kind) { + self.set_section(section); + } + return; + } + Some(ListTarget::ModuleJump { kind, module }) => { + self.search = builders::SearchFilters::empty(); + self.search.kind = kind; + self.search.module = module; + self.set_section(Section::Search); + return; + } + _ => {} + } if let Some(ListTarget::ProvenanceArtifact { .. }) = self.selected_target() { self.detail_tab = DetailTab::Provenance; } @@ -1184,19 +2007,421 @@ impl App { self.focus_previous(); } - pub fn focused_key(&mut self, key: KeyEvent) { - match self.focused { - ColumnFocus::Sections => self.section_key(key), - ColumnFocus::List => self.list_key(key), - ColumnFocus::Detail => self.detail_key(key), - } - } - - pub fn set_section_by_number(&mut self, number: usize) { - if (1..=SECTION_COUNT).contains(&number) { - self.set_section(Section::from_index(number - 1)); + /// Left click: focus the pane under the cursor; select the section, list + /// row, or detail tab it lands on. Clicks are discrete and idempotent, so + /// mapping them to selection is safe (unlike wheel events). + pub fn mouse_click(&mut self, x: u16, y: u16) { + if self.preview.is_some() + || self.help_state == HelpState::Open + || self.palette.is_open() + || self.comment_prompt.is_some() + { + return; } - } + let position = Position { x, y }; + let regions = self.mouse_regions; + if regions.tabs.contains(position) { + self.focused = ColumnFocus::Detail; + if y == regions.tabs.y + && let Some(tab) = tab_at_column(x.saturating_sub(regions.tabs.x)) + { + self.set_detail_tab(tab); + } + } else if regions.sections.contains(position) { + self.focused = ColumnFocus::Sections; + if let Some(row) = bordered_row_at(regions.sections, x, y) + && row < Section::ALL.len() + { + self.set_section(Section::ALL[row]); + } + } else if regions.list.contains(position) { + self.focused = ColumnFocus::List; + if let Some(visual_row) = bordered_row_at(regions.list, x, y) { + let row = visual_row.saturating_add(self.list_offset); + self.ensure_rows(); + let rows = self.cached_rows(); + let selectable = rows.get(row).is_some_and(ListRow::is_selectable); + let toggles = rows + .get(row) + .is_some_and(|hit| matches!(hit.target, ListTarget::OverviewMode)); + if selectable { + let already_selected = self.list_selected[self.section as usize] == row; + self.list_selected[self.section as usize] = row; + if toggles { + self.toggle_overview_mode(); + } else if already_selected { + // Click on the selected row activates it, gitui-style. + self.drill_or_expand(); + } + } + } + } else if regions.detail.contains(position) { + self.focused = ColumnFocus::Detail; + if regions.detail_body.contains(position) { + let row = usize::from(y.saturating_sub(regions.detail_body.y)) + .saturating_add(usize::from(self.detail_scroll)); + let link = self + .preview_cache + .as_ref() + .and_then(|cache| cache.links.get(row).cloned().flatten()); + if let Some(url) = link { + self.toast = Some(if open_in_browser(&url) { + format!("opened {url}") + } else { + format!("could not open {url}") + }); + } + } + } + } + + /// Mouse wheel scrolls the viewport under the cursor and never moves the + /// selection: passive trackpad gestures must not drag application state. + pub fn mouse_scroll(&mut self, x: u16, y: u16, down: bool) { + const WHEEL_STEP: u16 = 3; + if self.preview.is_some() { + if down { + self.preview_scroll_down(WHEEL_STEP); + } else { + self.preview_scroll_up(WHEEL_STEP); + } + return; + } + if self.help_state == HelpState::Open { + return; + } + let position = Position { x, y }; + if self.mouse_regions.detail.contains(position) { + self.detail_scroll = if down { + self.detail_scroll.saturating_add(WHEEL_STEP) + } else { + self.detail_scroll.saturating_sub(WHEEL_STEP) + }; + } else if self.mouse_regions.list.contains(position) { + // Viewport only; the render pass clamps to the row count. + self.list_offset = if down { + self.list_offset.saturating_add(usize::from(WHEEL_STEP)) + } else { + self.list_offset.saturating_sub(usize::from(WHEEL_STEP)) + }; + } + } + + pub fn focused_key(&mut self, key: KeyEvent) { + match self.focused { + ColumnFocus::Sections => self.section_key(key), + ColumnFocus::List => self.list_key(key), + ColumnFocus::Detail => self.detail_key(key), + } + } + + pub fn set_section_by_number(&mut self, number: usize) { + if (1..=SECTION_COUNT).contains(&number) { + self.set_section(Section::from_index(number - 1)); + } + } + + /// Toasts show until the next keypress, then yield the footer back to the + /// hint row. + pub fn clear_toast(&mut self) { + self.toast = None; + } + + pub fn set_toast(&mut self, message: String) { + self.toast = Some(message); + } + + /// Queue gitui (or jjui) for the selected repository; the event loop + /// suspends the TUI, runs the tool in the repo, and resumes on exit. + pub fn open_repo_tool(&mut self, jj: bool) { + let program = if jj { "jjui" } else { "gitui" }; + let name = match self.selected_target() { + Some(ListTarget::Module(name)) => name, + Some( + ListTarget::Artifact { module, .. } + | ListTarget::ProvenanceArtifact { module, .. } + | ListTarget::Companion { module, .. }, + ) => module, + Some(ListTarget::Adr { repo, .. }) => repo, + _ => { + self.toast = Some(format!("{program}: select a repository or artifact first")); + return; + } + }; + let Some(module) = self.view.modules.iter().find(|module| module.name == name) else { + return; + }; + let Some(path) = module.local_path.clone() else { + self.toast = Some(format!("{program}: no local clone for {name}")); + return; + }; + self.pending_external = Some(ExternalCommand { + program: program.to_string(), + args: Vec::new(), + directory: path, + }); + } + + pub fn take_external(&mut self) -> Option { + self.pending_external.take() + } + + /// Module owning the current selection, with its local repo path. + fn selected_module_repo(&self) -> Option<(String, PathBuf)> { + let name = match self.selected_target()? { + ListTarget::Module(name) => name, + ListTarget::Artifact { module, .. } + | ListTarget::ProvenanceArtifact { module, .. } + | ListTarget::Companion { module, .. } => module, + ListTarget::Adr { repo, .. } => repo, + _ => return None, + }; + let module = self + .view + .modules + .iter() + .find(|module| module.name == name)?; + let path = module.local_path.clone()?; + Some((name, path)) + } + + /// Scope of the current selection for deploy: single artifact when one + /// is selected (with its `--only` prefix), whole module otherwise. + fn selected_deploy_scope(&self) -> Option<(String, Option)> { + match self.selected_target()? { + ListTarget::Artifact { kind, name, .. } + | ListTarget::ProvenanceArtifact { kind, name, .. } => { + let artifact = self.selected_artifact()?; + let prefix = artifact_only_prefix(&kind, &artifact.relative_path); + Some(( + format!("{} {name}", kind.trim_end_matches('s')), + Some(prefix), + )) + } + ListTarget::Companion { parent, name, .. } => Some(( + format!("companion {parent}/{name}"), + Some(format!("skills/{parent}/{name}")), + )), + ListTarget::Module(name) => Some((format!("module {name}"), None)), + ListTarget::Adr { repo, .. } => Some((format!("module {repo}"), None)), + _ => None, + } + } + + /// Opens the deploy target picker for the current selection: the single + /// artifact when one is selected, its whole module otherwise. + pub fn open_deploy_picker(&mut self) { + let Some((module_name, source)) = self.selected_module_repo() else { + self.toast = Some("deploy: select an artifact or repository first".to_string()); + return; + }; + let Some((scope_label, only)) = self.selected_deploy_scope() else { + self.toast = Some("deploy: nothing deployable selected".to_string()); + return; + }; + let scope_label = if only.is_some() { + scope_label + } else { + format!("module {module_name}") + }; + let mut options: Vec<(String, PathBuf)> = Vec::new(); + if let Some(home) = dirs::home_dir() { + options.push(("user scope (~)".to_string(), home)); + } + options.push(( + format!("this project ({})", self.root.display()), + self.root.clone(), + )); + for location in &self.watched_locations { + options.push((format!("watched: {}", location.display()), location.clone())); + } + self.deploy_picker = Some(DeployPicker { + scope_label, + source, + only, + options, + selected: 0, + input: None, + }); + } + + #[must_use] + pub fn is_deploy_picker_open(&self) -> bool { + self.deploy_picker.is_some() + } + + /// One keypress inside the deploy picker: j/k select, Enter deploys to + /// the chosen target additively (install --no-prune, scoped by --only + /// when a single artifact is selected), the last row adds a new target + /// path, Esc closes. + pub fn deploy_picker_key(&mut self, key: KeyEvent) { + let Some(picker) = self.deploy_picker.as_mut() else { + return; + }; + if picker.input.is_some() { + self.deploy_input_key(key); + return; + } + let add_row = picker.options.len(); + match key.code { + KeyCode::Esc | KeyCode::Char('q') => self.deploy_picker = None, + KeyCode::Down | KeyCode::Char('j') => { + picker.selected = (picker.selected + 1).min(add_row); + } + KeyCode::Up | KeyCode::Char('k') => { + picker.selected = picker.selected.saturating_sub(1); + } + KeyCode::Enter if picker.selected == add_row => { + picker.input = Some("~/".to_string()); + } + KeyCode::Enter => { + let picker = self.deploy_picker.take().expect("picker is open"); + let Some((_, target)) = picker.options.get(picker.selected) else { + return; + }; + let program = std::env::current_exe() + .map_or_else(|_| "forge".to_string(), |exe| exe.display().to_string()); + let mut args = vec![ + "install".to_string(), + "--source".to_string(), + picker.source.display().to_string(), + "--target".to_string(), + target.display().to_string(), + "--no-prune".to_string(), + ]; + if let Some(prefix) = &picker.only { + args.push("--only".to_string()); + args.push(prefix.clone()); + } + self.pending_external = Some(ExternalCommand { + program, + args, + directory: picker.source.clone(), + }); + self.toast = Some(format!("deploying {} …", picker.scope_label)); + } + _ => {} + } + } + + /// Path entry for a new deploy target: typed, validated as an existing + /// directory, persisted to the watchlist, and selected. + fn deploy_input_key(&mut self, key: KeyEvent) { + let Some(picker) = self.deploy_picker.as_mut() else { + return; + }; + let Some(input) = picker.input.as_mut() else { + return; + }; + match key.code { + KeyCode::Esc => picker.input = None, + KeyCode::Backspace => { + input.pop(); + } + KeyCode::Char(character) => input.push(character), + KeyCode::Enter => { + let raw = input.trim().to_string(); + picker.input = None; + let expanded = expand_home(&raw); + let Ok(path) = std::fs::canonicalize(&expanded) else { + self.toast = Some(format!("not a directory: {raw}")); + return; + }; + if !path.is_dir() { + self.toast = Some(format!("not a directory: {raw}")); + return; + } + match watchlist::add_path_silent(&path.display().to_string()) { + Ok(_) => { + picker + .options + .push((format!("added: {}", path.display()), path)); + picker.selected = picker.options.len() - 1; + self.toast = Some("target added — Enter deploys to it".to_string()); + } + Err(error) => { + self.toast = Some(format!("could not save target: {error}")); + } + } + } + _ => {} + } + } + + /// Opens the harness picker for launching a session in the selected + /// module's repo: `FORGE_TUI_LAUNCH` first when set, then the harness + /// CLIs, then `forge launch` once this binary carries it. + pub fn launch_harness(&mut self) { + let Some((module_name, path)) = self.selected_module_repo() else { + self.toast = Some("launch: select an artifact or repository first".to_string()); + return; + }; + let mut options: Vec<(String, String)> = Vec::new(); + if let Ok(custom) = std::env::var("FORGE_TUI_LAUNCH") + && !custom.trim().is_empty() + { + options.push((format!("custom: {custom}"), custom)); + } + for harness in ["claude", "codex", "gemini", "opencode"] { + options.push((harness.to_string(), harness.to_string())); + } + self.launch_picker = Some(LaunchPicker { + module_name, + directory: path, + options, + selected: 0, + }); + } + + #[must_use] + pub fn is_launch_picker_open(&self) -> bool { + self.launch_picker.is_some() + } + + /// Whether a modal owns input: mouse events must not reach the panes + /// underneath (the fullscreen zoom keeps its wheel scrolling). + #[must_use] + pub fn modal_blocks_mouse(&self) -> bool { + self.is_help_open() + || self.is_palette_open() + || self.is_comment_prompt_open() + || self.deploy_picker.is_some() + || self.launch_picker.is_some() + } + + pub fn launch_picker_key(&mut self, key: KeyEvent) { + let Some(picker) = self.launch_picker.as_mut() else { + return; + }; + match key.code { + KeyCode::Esc | KeyCode::Char('q') => self.launch_picker = None, + KeyCode::Down | KeyCode::Char('j') => { + picker.selected = (picker.selected + 1).min(picker.options.len().saturating_sub(1)); + } + KeyCode::Up | KeyCode::Char('k') => { + picker.selected = picker.selected.saturating_sub(1); + } + KeyCode::Enter => { + let picker = self.launch_picker.take().expect("picker is open"); + let Some((label, command)) = picker.options.get(picker.selected) else { + return; + }; + // A custom command may carry arguments; std Command does not + // shell-split, so split on whitespace here. + let mut words = command.split_whitespace().map(str::to_string); + let Some(program) = words.next() else { + return; + }; + self.toast = Some(format!("launching {label} in {}", picker.module_name)); + self.pending_external = Some(ExternalCommand { + program, + args: words.collect(), + directory: picker.directory.clone(), + }); + } + _ => {} + } + } pub fn set_section_by_shortcut(&mut self, character: char) -> bool { let Some(section) = Section::from_shortcut(character) else { @@ -1207,6 +2432,9 @@ impl App { } pub fn set_detail_tab(&mut self, tab: DetailTab) { + if self.detail_tab == tab { + return; + } self.detail_tab = tab; self.detail_scroll = 0; } @@ -1217,6 +2445,36 @@ impl App { self.detail_tab } + #[cfg(test)] + pub fn set_module_local_path_for_test(&mut self, name: &str, path: PathBuf) { + if let Some(module) = self + .view + .modules + .iter_mut() + .find(|module| module.name == name) + { + module.local_path = Some(path); + } + } + + #[cfg(test)] + #[must_use] + pub fn focused_column(&self) -> ColumnFocus { + self.focused + } + + #[cfg(test)] + #[must_use] + pub fn detail_scroll_for_test(&self) -> u16 { + self.detail_scroll + } + + #[cfg(test)] + #[must_use] + pub fn selected_row_for_test(&self) -> usize { + self.list_selected[self.section as usize] + } + #[cfg(test)] #[must_use] pub fn section(&self) -> Section { @@ -1236,13 +2494,20 @@ impl App { #[must_use] pub fn is_search_input_active(&self) -> bool { - self.section == Section::Search && self.focused == ColumnFocus::List + self.section == Section::Search && self.focused == ColumnFocus::List && self.search_typing + } + + pub fn begin_search_input(&mut self) { + self.focused = ColumnFocus::List; + self.search_typing = true; } pub fn search_input_key(&mut self, key: KeyEvent) { match key.code { - KeyCode::Esc => self.focus_previous(), - KeyCode::Enter => self.clamp_list_selection(), + KeyCode::Esc | KeyCode::Enter => { + self.search_typing = false; + self.clamp_list_selection(); + } KeyCode::Backspace => { self.search.query.pop(); self.list_selected[self.section as usize] = 0; @@ -1293,8 +2558,15 @@ impl App { pub fn copy_selected(&mut self) { self.ensure_rows(); - if let Some(artifact) = self.selected_artifact() { - self.toast = Some(format!("copied source path: {}", artifact.relative_path)); + if let Some(path) = self + .selected_artifact() + .map(|artifact| artifact.relative_path.clone()) + { + self.toast = Some(if copy_to_pbcopy(&path) { + format!("copied source path: {path}") + } else { + "pbcopy unavailable".to_string() + }); } } @@ -1306,27 +2578,34 @@ impl App { let digest = self.tuicr_digest(); let copied = copy_to_pbcopy(&digest); - if !copied { - eprintln!("{digest}"); - } let count = self.comments.len(); self.toast = Some(if copied { format!("copied {count} comments") } else { - "pbcopy unavailable".to_string() + // stderr is invisible inside the alternate screen; a file is the + // only fallback that survives. + let fallback = std::env::temp_dir().join("forge-tuicr-review.md"); + match std::fs::write(&fallback, &digest) { + Ok(()) => format!( + "pbcopy unavailable — review written to {}", + fallback.display() + ), + Err(error) => format!("pbcopy unavailable and file write failed: {error}"), + } }); } #[cfg(test)] pub fn add_comment_for_test( &mut self, + module: impl Into, path: impl Into, line_number: usize, kind: CommentKind, text: impl Into, ) { self.comments.insert( - (path.into(), line_number), + (module.into(), path.into(), line_number), LineComment { kind, text: text.into(), @@ -1342,13 +2621,14 @@ impl App { String::new(), ]; lines.extend(self.comments.iter().enumerate().map( - |(index, ((path, line_number), comment))| { + |(index, ((module, path, line_number), comment))| { format!( - "{}. **[{}]** `{}:{}` - {}", + "{}. **[{}]** `{}:{}` ({}) - {}", index + 1, comment.kind.label(), path, line_number, + module, comment.text ) }, @@ -1357,18 +2637,37 @@ impl App { } fn open_comment_prompt(&mut self) { - let Some(artifact) = self.selected_artifact() else { + let Some((module, path, code_line)) = self.selected_artifact().map(|artifact| { + ( + artifact.module.clone(), + artifact.relative_path.clone(), + self.current_code_line(artifact), + ) + }) else { return; }; - let path = artifact.relative_path.clone(); - let line_number = self.current_code_line(artifact); + let line_number = if self.detail_tab == DetailTab::Diff { + let mapped = self + .preview_cache + .as_ref() + .and_then(|cache| cache.line_map.get(self.detail_cursor).copied().flatten()); + let Some(line) = mapped else { + self.toast = + Some("no source line here — move to an added or context row".to_string()); + return; + }; + line + } else { + code_line + }; let (kind, text) = self .comments - .get(&(path.clone(), line_number)) + .get(&(module.clone(), path.clone(), line_number)) .map_or((CommentKind::Issue, String::new()), |comment| { (comment.kind, comment.text.clone()) }); self.comment_prompt = Some(CommentPrompt { + module, path, line_number, kind, @@ -1382,12 +2681,13 @@ impl App { }; let text = prompt.text.trim().to_string(); if text.is_empty() { - self.comments.remove(&(prompt.path, prompt.line_number)); + self.comments + .remove(&(prompt.module, prompt.path, prompt.line_number)); self.toast = Some("comment cleared".to_string()); return; } self.comments.insert( - (prompt.path, prompt.line_number), + (prompt.module, prompt.path, prompt.line_number), LineComment { kind: prompt.kind, text, @@ -1419,40 +2719,57 @@ impl App { KeyCode::Home | KeyCode::Char('g') => self.select_first_row(), KeyCode::End | KeyCode::Char('G') => self.select_last_row(), KeyCode::Char('m') if self.section == Section::Overview => { - self.overview_mode = match self.overview_mode { - OverviewMode::Nested => OverviewMode::Matrix, - OverviewMode::Matrix => OverviewMode::Nested, - }; - self.invalidate_rows(); + self.toggle_overview_mode(); + } + KeyCode::Char('m') if self.selected_artifact().is_some() => { + self.focused = ColumnFocus::Detail; + self.set_detail_tab(DetailTab::Code); + self.open_comment_prompt(); } _ => {} } } fn detail_key(&mut self, key: KeyEvent) { + let page = isize::try_from(self.detail_viewport.max(2) - 1).unwrap_or(10); + let half = (page / 2).max(1); match key.code { - KeyCode::Down | KeyCode::Char('j') => { - self.detail_scroll = self.detail_scroll.saturating_add(1); + KeyCode::Down | KeyCode::Char('j') => self.detail_step(1), + KeyCode::Up | KeyCode::Char('k') => self.detail_step(-1), + KeyCode::PageDown | KeyCode::Char(' ') => self.detail_step(page), + KeyCode::PageUp | KeyCode::Char('b') => self.detail_step(-page), + KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.detail_step(half); } - KeyCode::Up | KeyCode::Char('k') => { - self.detail_scroll = self.detail_scroll.saturating_sub(1); - } - KeyCode::PageDown => { - self.detail_scroll = self.detail_scroll.saturating_add(10); - } - KeyCode::PageUp => { - self.detail_scroll = self.detail_scroll.saturating_sub(10); - } - KeyCode::Home | KeyCode::Char('g') => self.detail_scroll = 0, - KeyCode::Char('p' | '1') => self.set_detail_tab(DetailTab::Preview), - KeyCode::Char('c' | '2') => self.set_detail_tab(DetailTab::Code), - KeyCode::Char('d' | '3') => self.set_detail_tab(DetailTab::Diff), - KeyCode::Char('4') => self.set_detail_tab(DetailTab::Provenance), - KeyCode::Char('5') => self.set_detail_tab(DetailTab::Frontmatter), - KeyCode::Char('6') => self.set_detail_tab(DetailTab::History), - KeyCode::Char('7') => self.set_detail_tab(DetailTab::Companions), + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.detail_step(-half); + } + KeyCode::Char(digit @ '1'..='6') => { + let index = usize::from(digit as u8 - b'1'); + self.set_detail_tab(DetailTab::ALL[index]); + } + KeyCode::Home | KeyCode::Char('g') => { + self.detail_cursor = 0; + self.detail_scroll = 0; + } + KeyCode::End | KeyCode::Char('G') => { + self.detail_cursor = usize::MAX; + self.detail_scroll = u16::MAX; + self.move_detail_cursor(0); + } + KeyCode::Char(']') if self.detail_tab == DetailTab::Diff => self.jump_hunk(true), + KeyCode::Char('[') if self.detail_tab == DetailTab::Diff => self.jump_hunk(false), + KeyCode::Char('p') => self.set_detail_tab(DetailTab::Preview), + KeyCode::Char('c') => self.set_detail_tab(DetailTab::Code), + KeyCode::Char('d') => self.set_detail_tab(DetailTab::Diff), + KeyCode::Char('v') => self.set_detail_tab(DetailTab::Provenance), + KeyCode::Char('f') => self.set_detail_tab(DetailTab::Frontmatter), + KeyCode::Char('i') => self.set_detail_tab(DetailTab::History), KeyCode::Tab => self.next_detail_tab(), - KeyCode::Char('m') if self.detail_tab == DetailTab::Code => { + KeyCode::Char('m') => { + if !matches!(self.detail_tab, DetailTab::Code | DetailTab::Diff) { + self.set_detail_tab(DetailTab::Code); + } self.open_comment_prompt(); } _ => {} @@ -1467,13 +2784,29 @@ impl App { fn set_section(&mut self, section: Section) { self.section = section; self.detail_scroll = 0; + self.list_offset = 0; + self.list_filter.clear(); + self.list_filter_typing = false; + self.problems_only = false; self.invalidate_rows(); self.clamp_list_selection(); } fn ensure_rows(&mut self) { if self.rows_dirty { - self.cached_rows = self.build_list_rows(); + let mut rows = self.build_list_rows(); + if !self.list_filter.is_empty() { + let needle = self.list_filter.to_lowercase(); + rows.retain(|row| { + row.header + || row.label.to_lowercase().contains(&needle) + || row.detail.to_lowercase().contains(&needle) + }); + } + if self.problems_only { + rows.retain(|row| row.header || matches!(row.status, "modified" | "stale" | "new")); + } + self.cached_rows = rows; self.column_widths = column_widths_for_rows(&self.cached_rows); self.rows_dirty = false; #[cfg(test)] @@ -1483,6 +2816,57 @@ impl App { } } + /// One keypress editing the in-panel filter: Enter keeps it, Esc clears. + pub fn list_filter_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Enter => self.list_filter_typing = false, + KeyCode::Esc => { + self.list_filter_typing = false; + self.list_filter.clear(); + self.invalidate_rows(); + self.clamp_list_selection(); + } + KeyCode::Backspace => { + self.list_filter.pop(); + self.list_selected[self.section as usize] = 0; + self.list_offset = 0; + self.invalidate_rows(); + } + KeyCode::Char(character) => { + self.list_filter.push(character); + self.list_selected[self.section as usize] = 0; + self.list_offset = 0; + self.invalidate_rows(); + } + _ => {} + } + } + + #[must_use] + pub fn is_list_filter_typing(&self) -> bool { + self.list_filter_typing + } + + /// Opens the in-panel filter on the focused list. In the Search section + /// `/` edits the global query instead — that list IS the query results. + pub fn begin_list_filter(&mut self) { + if self.section == Section::Search { + self.begin_search_input(); + return; + } + self.focused = ColumnFocus::List; + self.list_filter_typing = true; + } + + /// Toggles the problems-only view of the focused list. + pub fn toggle_problems_only(&mut self) { + self.problems_only = !self.problems_only; + self.list_selected[self.section as usize] = 0; + self.list_offset = 0; + self.invalidate_rows(); + self.clamp_list_selection(); + } + fn invalidate_rows(&mut self) { self.rows_dirty = true; } @@ -1492,6 +2876,41 @@ impl App { self.code_cache = None; } + /// After a rescan the zoom overlay's cloned artifact is stale: rebind it + /// to the fresh view, or close it when the artifact no longer exists. + fn refresh_open_preview(&mut self) { + let Some((open, scroll)) = self.preview.as_ref().map(|preview| { + let artifact = preview.artifact(); + ( + ( + artifact.module.clone(), + artifact.kind.clone(), + artifact.name.clone(), + ), + preview.scroll(), + ) + }) else { + return; + }; + let fresh = self + .view + .modules + .iter() + .find(|module| module.name == open.0) + .and_then(|module| { + module + .artifacts + .iter() + .find(|artifact| artifact.kind == open.1 && artifact.name == open.2) + }) + .cloned(); + self.preview = fresh.map(|artifact| { + let mut preview = ArtifactPreview::from_artifact(&artifact); + preview.scroll_down(scroll); + preview + }); + } + fn cached_rows(&self) -> &[ListRow] { &self.cached_rows } @@ -1534,19 +2953,56 @@ impl App { } fn overview_rows(&self) -> Vec { - vec![ + let summary = &self.view.summary; + let mut rows = vec![ ListRow::item("Summary", "status counts", ListTarget::Overview, "ok"), ListRow::item( if self.overview_mode == OverviewMode::Matrix { - "Matrix" + "Matrix view" } else { - "Nested" + "Nested view" }, - "press m to toggle", - ListTarget::Overview, + "Enter or click toggles", + ListTarget::OverviewMode, "ok", ), - ] + ]; + rows.push(ListRow::header("Needs attention")); + for (status, count) in [ + ("modified", summary.modified), + ("stale", summary.stale), + ("new", summary.new), + ] { + if count > 0 { + rows.push(ListRow::item( + format!("{status} {count}"), + "Enter opens filtered", + ListTarget::StatusJump(status.to_string()), + status, + )); + } + } + rows.push(ListRow::header("Inventory")); + for group in builders::build_nested(&self.view, "kind") { + rows.push(ListRow::item( + format!("{} ({})", group.label, group.count), + String::new(), + ListTarget::KindJump(group.kind.clone()), + "ok", + )); + for subgroup in group.subgroups { + rows.push(ListRow::item( + format!(" {} ({})", subgroup.label, subgroup.count), + String::new(), + ListTarget::ModuleJump { + kind: group.kind.clone(), + module: subgroup.label.clone(), + }, + "ok", + )); + } + } + rows } fn artifact_rows(&self, kind_filter: Option<&str>) -> Vec { @@ -1558,6 +3014,18 @@ impl App { rows.push(ListRow::header(kind)); for (artifact, module) in artifacts { rows.push(artifact_row(artifact, module)); + for companion in &artifact.companions { + rows.push(ListRow::item( + format!(" ↳ {}", companion.name), + format!("companion of {}", artifact.name), + ListTarget::Companion { + module: module.to_string(), + parent: artifact.name.clone(), + name: companion.name.clone(), + }, + "ok", + )); + } } } rows @@ -1652,7 +3120,7 @@ impl App { let qualifier = coverage.cols[index].qualifier.clone(); rows.push(ListRow::item( row.name.clone(), - format!("{} · {} · {}", row.kind, qualifier, cell.mode), + format!("{qualifier} · {}", cell.mode), ListTarget::Variant { module: row.module.clone(), kind: row.kind.clone(), @@ -1668,8 +3136,13 @@ impl App { fn search_rows(&self) -> Vec { let mut rows = vec![ListRow::header(format!( - "query: {} kind: {} status: {} sort: {}", + "query: {}{} kind: {} status: {} sort: {}", value_or_any(&self.search.query), + if self.search_typing { + "▌ (Enter done)" + } else { + " (/ edits)" + }, value_or_any(&self.search.kind), value_or_any(&self.search.status), value_or_any(&self.search.sort) @@ -1764,6 +3237,19 @@ impl App { .unwrap_or_default() } + /// Re-selects the row carrying the same target after the rows were + /// rebuilt, so a background rescan does not silently move the selection + /// to whatever row now occupies the old index. + fn restore_selection(&mut self, target: Option) { + let Some(target) = target else { + return; + }; + self.ensure_rows(); + if let Some(index) = self.cached_rows.iter().position(|row| row.target == target) { + self.list_selected[self.section as usize] = index; + } + } + fn clamp_list_selection(&mut self) { self.ensure_rows(); let rows = self.cached_rows(); @@ -1771,7 +3257,7 @@ impl App { self.list_selected[self.section as usize] = index; } - fn move_list_selection(&mut self, delta: isize) { + pub fn move_list_selection(&mut self, delta: isize) { self.ensure_rows(); let rows = self.cached_rows(); if rows.is_empty() { @@ -1832,9 +3318,34 @@ impl App { fn current_code_line(&self, artifact: &ArtifactView) -> usize { let line_count = artifact.raw_source.lines().count().max(1); - usize::from(self.detail_scroll) - .saturating_add(1) - .min(line_count) + self.detail_cursor.saturating_add(1).min(line_count) + } + + /// Moves the Code cursor and drags the viewport along only when the + /// cursor leaves it. + fn move_detail_cursor(&mut self, delta: isize) { + let total = match self.detail_tab { + DetailTab::Code => self + .code_cache + .as_ref() + .map_or(1, |cache| cache.lines.len().max(1)), + _ => self + .preview_cache + .as_ref() + .map_or(1, |cache| cache.lines.len().max(1)), + }; + let cursor = self + .detail_cursor + .saturating_add_signed(delta) + .min(total - 1); + self.detail_cursor = cursor; + let scroll = usize::from(self.detail_scroll); + let viewport = self.detail_viewport.max(1); + if cursor < scroll { + self.detail_scroll = u16::try_from(cursor).unwrap_or(u16::MAX); + } else if cursor >= scroll + viewport { + self.detail_scroll = u16::try_from(cursor + 1 - viewport).unwrap_or(u16::MAX); + } } fn find_artifact( @@ -1921,26 +3432,42 @@ impl App { } fn provenance_lines(&self, module: &ModuleView, artifact: &ArtifactView) -> Vec> { + fn field(key: &str, value: String) -> Line<'static> { + Line::from(vec![ + Span::styled(format!("{key:<14}"), Style::default().fg(Color::Magenta)), + Span::raw(value), + ]) + } + fn field_if(key: &str, value: &str) -> Option> { + (!value.trim().is_empty()).then(|| field(key, value.to_string())) + } + let short = |sha: &str| sha.chars().take(12).collect::(); + let mut lines = vec![ Line::from(Span::styled( - "Provenance chain", + "Provenance", Style::default().add_modifier(Modifier::BOLD), )), - Line::from(format!( - "status: {} · {}", - artifact.overall_status(), - artifact.staleness_label() - )), + field( + "status", + format!( + "{} · {}", + artifact.overall_status(), + artifact.staleness_label() + ), + ), ]; if let Some(adoption) = &artifact.adoption { - lines.push(Line::from(format!( - "Upstream: {} @ {}", - adoption.source_label, adoption.source_sha - ))); - lines.push(Line::from(format!( - "adopt/copy: {} · by {}", - adoption.kind, adoption.author - ))); + lines.push(field( + "upstream", + format!( + "{} @ {}", + adoption.source_label, + short(&adoption.source_sha) + ), + )); + lines.push(field("adopted", adoption.kind.clone())); + lines.extend(field_if("author", &adoption.author)); if !adoption.dependencies.is_empty() { let deps = builders::resolve_dep_links(&self.view, artifact.adoption.as_ref()) .iter() @@ -1948,96 +3475,148 @@ impl App { if dep.module.is_empty() { dep.name.clone() } else { - format!("{} -> {}", dep.name, dep.module) + format!("{} ({})", dep.name, dep.module) } }) .collect::>() .join(", "); - lines.push(Line::from(format!("dependencies: {deps}"))); + lines.push(field("depends on", deps)); } if !adoption.transforms.is_empty() { - lines.push(Line::from(format!( - "transforms: {}", - adoption.transforms.join(", ") - ))); + lines.push(field("transforms", adoption.transforms.join(", "))); } - lines.push(Line::from(format!("license: {}", adoption.license))); - lines.push(Line::from(format!("adopted by: {}", adoption.adopted_by))); - } else { - lines.push(Line::from("Upstream: authored source")); - } - lines.push(Line::from(format!("source module: {}", module.name))); - lines.push(Line::from( - "assemble: current binary metadata available in dashboard", - )); - let entries = self.provenance_entries(module, artifact); - let groups = builders::group_deployments(&entries); - if groups.is_empty() { - lines.push(Line::from("deploy groups: none")); + lines.extend(field_if("license", &adoption.license)); + lines.extend(field_if("adopted by", &adoption.adopted_by)); } else { - lines.push(Line::from("deploy groups")); - for group in groups { - lines.push(Line::from(format!( - " {} {}/{} verified", - group.target, group.verified, group.total - ))); - for harness in group.harnesses { - lines.push(Line::from(format!( - " {} {} {}", - harness.harness, - if harness.verified { "OK" } else { "DRIFT" }, - harness.deployed_path - ))); - } - } + lines.push(field("upstream", "authored here".to_string())); } + lines.push(field("source", module.name.clone())); + + let entries = self.provenance_entries(module, artifact); + let groups = builders::group_deployments(&entries); + lines.push(Line::default()); + lines.extend(deployment_lines(&groups)); if !artifact.sidecar_warning.is_empty() { - lines.push(Line::from(format!("sidecar: {}", artifact.sidecar_warning))); + lines.push(field("sidecar", artifact.sidecar_warning.clone())); } + lines.extend(sidecar_yaml_lines(module, artifact)); lines } } -fn render_module_detail(frame: &mut Frame<'_>, area: Rect, module: &ModuleView) { - let lines = vec![ +/// The raw adoption sidecar, syntax-highlighted as YAML, appended to the +/// provenance chain when the file exists next to the source. +fn sidecar_yaml_lines(module: &ModuleView, artifact: &ArtifactView) -> Vec> { + let Some(repo) = module.local_path.as_ref() else { + return Vec::new(); + }; + let source = if artifact.source_path.is_empty() { + artifact.relative_path.as_str() + } else { + artifact.source_path.as_str() + }; + let sidecar = Path::new(source).with_extension("yaml"); + let Ok(content) = std::fs::read_to_string(repo.join(&sidecar)) else { + return Vec::new(); + }; + let mut lines = vec![ + Line::from(""), + Line::from(Span::styled( + format!("Sidecar · {}", sidecar.display()), + Style::default().add_modifier(Modifier::BOLD), + )), + ]; + lines.extend(rich::highlight_code( + &sidecar.to_string_lossy(), + content.trim_end(), + )); + lines +} + +fn module_header_lines(module: &ModuleView) -> Vec> { + let mut lines = vec![ Line::from(Span::styled( module.name.clone(), Style::default().add_modifier(Modifier::BOLD), )), - Line::from(format!("version: {}", module.version)), - Line::from(format!("source: {}", module.source_uri)), Line::from(format!( "role: {}", if module.is_target { "target" } else { "source" } )), - Line::from(""), - Line::from(module.description.clone()), - Line::from(""), - Line::from(format!("artifacts: {}", module.artifacts.len())), ]; - frame.render_widget( - Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }), - area, - ); + if !module.version.is_empty() { + lines.insert(1, Line::from(format!("version: {}", module.version))); + } + if !module.source_uri.is_empty() { + lines.insert(1, Line::from(format!("source: {}", module.source_uri))); + } + if let Some(local_path) = &module.local_path { + lines.push(Line::from(format!("local: {}", local_path.display()))); + } + if let Some(vcs) = &module.vcs { + lines.push(module_vcs_line(vcs)); + } + if !module.description.is_empty() { + lines.push(Line::from("")); + lines.push(Line::from(module.description.clone())); + } + lines.push(Line::from("")); + lines.push(Line::from(format!("artifacts: {}", module.artifacts.len()))); + if !module.git_log.is_empty() { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "Recent commits (git)", + Style::default().add_modifier(Modifier::BOLD), + ))); + for commit in &module.git_log { + let sha_short: String = commit.sha.chars().take(7).collect(); + let date: String = commit.date.chars().take(10).collect(); + let mut spans = vec![ + Span::styled( + format!("{sha_short} {date} "), + Style::default().fg(Color::DarkGray), + ), + Span::raw(commit.message.clone()), + ]; + if !commit.jj_change.is_empty() { + spans.push(Span::styled( + format!(" · jj {}", commit.jj_change), + Style::default().fg(Color::DarkGray), + )); + } + lines.push(Line::from(spans)); + } + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "o open gitui · O open jjui", + Style::default().fg(Color::DarkGray), + ))); + } + lines } -fn render_adr_detail(frame: &mut Frame<'_>, area: Rect, adr: &Adr) { - let lines = vec![ - Line::from(Span::styled( - format!("{} {}", adr.id, adr.title), - Style::default().add_modifier(Modifier::BOLD), - )), - Line::from(format!("repo: {}", adr.repo)), - Line::from(format!("state: {}", adr.state)), - Line::from(format!("status: {}", adr.status)), - Line::from(format!("path: {}", adr.relative_path)), - Line::from(""), - Line::from(adr.summary.clone()), - ]; - frame.render_widget( - Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }), - area, - ); +fn module_vcs_line(vcs: &VcsState) -> Line<'static> { + use std::fmt::Write as _; + let mut branch = vcs.branch.clone(); + if vcs.ahead > 0 { + let _ = write!(branch, " ↑{}", vcs.ahead); + } + if vcs.behind > 0 { + let _ = write!(branch, " ↓{}", vcs.behind); + } + if vcs.jj_colocated { + branch.push_str(" · jj"); + } + let (state_label, state_style) = match vcs.worktree { + WorktreeState::Clean => ("✓ clean", Style::default().fg(Color::Green)), + WorktreeState::Modified => ("⚠ uncommitted changes", Style::default().fg(Color::Yellow)), + WorktreeState::Untracked => ("● untracked", Style::default().fg(Color::Magenta)), + }; + Line::from(vec![ + Span::styled(branch, Style::default().fg(Color::Cyan)), + Span::raw(" · "), + Span::styled(state_label, state_style), + ]) } fn render_file_body(frame: &mut Frame<'_>, area: Rect, content: &str, scroll: u16) { @@ -2175,13 +3754,16 @@ fn column_widths_for_rows(rows: &[ListRow]) -> MillerColumnWidths { let left = usize_to_u16(section_label_width.saturating_add(6)).clamp(LEFT_MIN_WIDTH, LEFT_MAX_WIDTH); - let row_label_width = rows + // Detail text renders only on the selected row, so the column sizes to + // the labels; the selected row's detail may clip at the column edge and + // is always fully visible in the detail pane. + let row_width = rows .iter() .map(|row| row.label.chars().count()) .max() .unwrap_or_default(); let middle = - usize_to_u16(row_label_width.saturating_add(8)).clamp(MIDDLE_MIN_WIDTH, MIDDLE_MAX_WIDTH); + usize_to_u16(row_width.saturating_add(8)).clamp(MIDDLE_MIN_WIDTH, MIDDLE_MAX_WIDTH); MillerColumnWidths { left, middle } } @@ -2227,21 +3809,9 @@ fn artifact_row(artifact: &ArtifactView, module: &str) -> ListRow { } else { "" }; - let age = artifact.age_label(); - let deploy = if artifact.deployed_count() == 0 { - "src".to_string() - } else { - format!("↗{}", artifact.deployed_count()) - }; ListRow::item( format!("{}{}", artifact.name, warning), - format!( - "{} · {} · {} · {}", - artifact.kind, - module, - value_or_any(&age), - deploy - ), + module.to_string(), ListTarget::Artifact { module: module.to_string(), kind: artifact.kind.clone(), @@ -2251,20 +3821,22 @@ fn artifact_row(artifact: &ArtifactView, module: &str) -> ListRow { ) } +/// gitui-style status letters: shape carries the state, color reinforces it. fn status_dot(status: &str) -> &'static str { match status { - "modified" | "stale" | "new" => "●", + "modified" => "M", + "stale" => "!", + "new" => "?", _ => "·", } } fn status_style(status: &str) -> Style { match status { - "modified" => Style::default().fg(Color::Red), - "stale" => Style::default().fg(Color::Yellow), - "new" => Style::default().fg(Color::Blue), - "source" => Style::default().fg(Color::DarkGray), - _ => Style::default().fg(Color::Green), + "modified" => Style::default().fg(Color::Yellow), + "stale" => Style::default().fg(Color::Red), + "new" => Style::default().fg(Color::Magenta), + _ => Style::default().fg(Color::DarkGray), } } @@ -2272,17 +3844,242 @@ fn value_or_any(value: &str) -> &str { if value.is_empty() { "any" } else { value } } -fn hint_row() -> String { - KEYBINDINGS +/// Row index inside a bordered block for a click at (x, y), `None` when the +/// click lands on the border itself — borders focus a pane but never select. +fn bordered_row_at(region: Rect, x: u16, y: u16) -> Option { + let inside_x = x > region.x && x.saturating_add(1) < region.x.saturating_add(region.width); + let inside_y = y > region.y && y.saturating_add(1) < region.y.saturating_add(region.height); + (inside_x && inside_y).then(|| usize::from(y - region.y - 1)) +} + +/// Maps a column inside the tab bar to its tab, mirroring the span layout in +/// `render_tabs`: one space then the label per tab. The space before a label +/// snaps to that tab so there are no dead cells between targets. +fn tab_at_column(column: u16) -> Option { + let mut cursor = 0u16; + for (index, tab) in DetailTab::ALL.iter().enumerate() { + let label = format!("{} {}", index + 1, tab.label()); + let width = u16::try_from(label.chars().count()).unwrap_or(u16::MAX); + let end = cursor.saturating_add(1).saturating_add(width); + if column < end { + return Some(*tab); + } + cursor = end; + } + None +} + +/// Centered modal listing options, used by the deploy and launch pickers. +fn render_choice_popup( + frame: &mut Frame<'_>, + area: Rect, + title: &str, + footer: &str, + labels: &[String], + selected: usize, + input: Option<&str>, +) { + let height = u16::try_from(labels.len()) + .unwrap_or(u16::MAX) + .saturating_add(if input.is_some() { 5 } else { 4 }); + let width = area.width.saturating_mul(2) / 3; + let popup = Rect { + x: area.width.saturating_sub(width) / 2, + y: area.height.saturating_sub(height) / 2, + width: width.min(area.width), + height: height.min(area.height), + }; + frame.render_widget(Clear, popup); + let block = Block::default() + .title(title.to_string()) + .title_bottom(Line::from(Span::styled( + footer.to_string(), + Style::default().fg(Color::DarkGray), + ))) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + let inner = block.inner(popup); + frame.render_widget(block, popup); + let viewport = usize::from(inner.height.max(1)).saturating_sub(usize::from(input.is_some())); + let offset = (selected + 1).saturating_sub(viewport); + let mut items: Vec> = labels + .iter() + .enumerate() + .skip(offset) + .take(viewport) + .map(|(index, label)| { + let style = if index == selected && input.is_none() { + selected_style(true) + } else { + Style::default() + }; + ListItem::new(Line::from(Span::styled(label.clone(), style))) + }) + .collect(); + if let Some(path) = input { + items.push(ListItem::new(Line::from(vec![ + Span::styled("path: ", Style::default().fg(Color::Magenta)), + Span::raw(path.to_string()), + Span::styled("▌", Style::default().fg(Color::Cyan)), + ]))); + } + frame.render_widget(List::new(items), inner); +} + +fn render_deploy_picker(frame: &mut Frame<'_>, area: Rect, picker: &DeployPicker) { + let mut labels: Vec = picker + .options + .iter() + .map(|(label, _)| label.clone()) + .collect(); + labels.push("+ add target path…".to_string()); + render_choice_popup( + frame, + area, + &format!(" Deploy {} → ", picker.scope_label), + " j/k select · Enter deploy (additive) · Esc cancel ", + &labels, + picker.selected, + picker.input.as_deref(), + ); +} + +fn render_launch_picker(frame: &mut Frame<'_>, area: Rect, picker: &LaunchPicker) { + let labels: Vec = picker + .options .iter() - .flat_map(|(_, bindings)| bindings.iter()) - .take(8) - .map(|(key, description)| format!("{key} {description}")) - .collect::>() - .join(" · ") + .map(|(label, _)| label.clone()) + .collect(); + render_choice_popup( + frame, + area, + &format!(" Launch in {} → ", picker.module_name), + " j/k select · Enter launch · Esc cancel ", + &labels, + picker.selected, + None, + ); +} + +fn hint_row(focused: ColumnFocus) -> String { + if focused == ColumnFocus::Detail { + return [ + "1-6/Tab tabs", + "j/k move", + "m comment", + "[ ] hunks", + "Y copy review", + "? help", + ] + .join(" · "); + } + match focused { + ColumnFocus::Sections => "j/k sections · l list · 1-6 tabs · ? help".to_string(), + _ => [ + "j/k move", + "/ filter", + "! problems", + "Enter open", + "D deploy", + "L launch", + "? help", + ] + .join(" · "), + } +} + +/// Keeps the rightmost display columns of `text`, prefixed with an ellipsis. +/// Returns the string and its display width (ellipsis included). +fn truncate_left_to_width(text: &str, take: usize) -> (String, usize) { + let mut width = 0usize; + let mut kept = Vec::new(); + for character in text.chars().rev() { + let character_width = UnicodeWidthChar::width(character).unwrap_or(0); + if width + character_width > take { + break; + } + width += character_width; + kept.push(character); + } + kept.reverse(); + let tail: String = kept.into_iter().collect(); + (format!("…{tail}"), width + 1) } -fn preview_lines_for_width(artifact: &ArtifactView, width: u16) -> Vec> { +/// Greedy word-wrap for plain header text, needed because the preview +/// paragraph does not re-wrap glow output. A single word longer than the +/// width stays on its own line and clips. +fn wrap_plain(text: &str, width: usize) -> Vec> { + if width == 0 { + return vec![Line::from(text.to_string())]; + } + // Explicit newlines are paragraph structure; wrap each line separately. + if text.contains('\n') { + return text + .lines() + .flat_map(|line| wrap_plain(line, width)) + .collect(); + } + let mut lines = Vec::new(); + let mut current = String::new(); + for word in text.split_whitespace() { + let candidate = current.chars().count() + 1 + word.chars().count(); + if !current.is_empty() && candidate > width { + lines.push(Line::from(std::mem::take(&mut current))); + } + if !current.is_empty() { + current.push(' '); + } + current.push_str(word); + } + if !current.is_empty() { + lines.push(Line::from(current)); + } + lines +} + +/// One line of version-control truth for the artifact: branch (with +/// ahead/behind arrows), worktree state, last commit, and jj change id. +fn vcs_line(artifact: &ArtifactView) -> Option> { + use std::fmt::Write as _; + let vcs = artifact.vcs.as_ref()?; + let mut branch = vcs.branch.clone(); + if vcs.ahead > 0 { + let _ = write!(branch, " ↑{}", vcs.ahead); + } + if vcs.behind > 0 { + let _ = write!(branch, " ↓{}", vcs.behind); + } + let mut spans = vec![ + Span::styled(branch, Style::default().fg(Color::Cyan)), + Span::raw(" · "), + ]; + let (worktree_label, worktree_style) = match vcs.worktree { + WorktreeState::Clean => ("✓ committed", Style::default().fg(Color::Green)), + WorktreeState::Modified => ("⚠ uncommitted changes", Style::default().fg(Color::Yellow)), + WorktreeState::Untracked => ("● untracked", Style::default().fg(Color::Magenta)), + }; + spans.push(Span::styled(worktree_label, worktree_style)); + if let Some(commit) = artifact.git_log.first() { + let sha_short: String = commit.sha.chars().take(7).collect(); + let date: String = commit.date.chars().take(10).collect(); + spans.push(Span::styled( + format!(" · {sha_short} {date}"), + Style::default().fg(Color::DarkGray), + )); + if !commit.jj_change.is_empty() { + spans.push(Span::styled( + format!(" · jj {}", commit.jj_change), + Style::default().fg(Color::DarkGray), + )); + } + } else if vcs.jj_colocated { + spans.push(Span::styled(" · jj", Style::default().fg(Color::DarkGray))); + } + Some(Line::from(spans)) +} + +fn preview_lines_for_width(artifact: &ArtifactView, width: u16) -> (Vec>, bool) { let mut lines = vec![ Line::from(Span::styled( format!( @@ -2303,15 +4100,23 @@ fn preview_lines_for_width(artifact: &ArtifactView, width: u16) -> Vec Vec String { + if kind == "skills" { + let segments: Vec<&str> = relative_path.split('/').take(2).collect(); + return format!("{}/", segments.join("/")); + } + relative_path + .rsplit_once('.') + .map_or(relative_path, |(stem, _)| stem) + .to_string() +} + +/// Expands a leading `~` to the home directory. +fn expand_home(raw: &str) -> PathBuf { + if raw == "~" + && let Some(home) = dirs::home_dir() + { + return home; + } + if let Some(rest) = raw.strip_prefix("~/") + && let Some(home) = dirs::home_dir() + { + return home.join(rest); + } + PathBuf::from(raw) +} + +/// The module name disambiguates artifacts that share a relative path across +/// modules (every module has a `skills/...` tree). +fn detail_cache_key(tab: DetailTab, module: &str, path: &str) -> String { + format!("{tab:?}:{module}:{path}") +} + +/// Whether unprocessed terminal input is queued. Errors (no terminal, as in +/// tests and snapshot mode) count as no pending input. +fn input_pending() -> bool { + crossterm::event::poll(std::time::Duration::ZERO).unwrap_or(false) +} + +/// Uncommitted changes to the artifact's source file, colored like a pager, +/// with a separator rule before each hunk header. +fn diff_lines( + module: Option<&ModuleView>, + artifact: &ArtifactView, + width: u16, +) -> Vec> { + let header = Line::from(Span::styled( + "Diff · uncommitted source changes", + Style::default().add_modifier(Modifier::BOLD), + )); + let Some(repo) = module.and_then(|module| module.local_path.as_ref()) else { + return vec![header, Line::from("no local repo for this module")]; + }; + let path = if artifact.source_path.is_empty() { + artifact.relative_path.as_str() + } else { + artifact.source_path.as_str() + }; + if artifact + .vcs + .as_ref() + .is_some_and(|vcs| vcs.worktree == WorktreeState::Untracked) + { + // A new file has no diff against HEAD; show the whole body as added + // so the reviewer can still inspect it here. + let mut lines = vec![ + header, + Line::from(vec![ + Span::styled("● ", Style::default().fg(Color::Magenta)), + Span::raw("untracked file — whole body is new"), + ]), + Line::default(), + ]; + match std::fs::read_to_string(repo.join(path)) { + Ok(body) => lines.extend(body.lines().enumerate().map(|(index, line)| { + Line::from(vec![ + Span::styled( + format!("{:>4} {:>4} ", "", index + 1), + Style::default().fg(Color::DarkGray), + ), + Span::styled(format!("+{line}"), Style::default().fg(Color::Green)), + ]) + })), + Err(error) => lines.push(Line::from(format!("could not read {path}: {error}"))), + } + return lines; + } + let output = std::process::Command::new("git") + .args(["diff", "HEAD", "--", path]) + .current_dir(repo) + .output(); + let Ok(output) = output else { + return vec![header, Line::from("git diff failed to run")]; + }; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return vec![ + header, + Line::from(format!("git diff failed: {}", stderr.trim())), + ]; + } + let diff = String::from_utf8_lossy(&output.stdout); + if diff.trim().is_empty() { + return vec![ + header, + Line::from(vec![ + Span::styled("✓ ", Style::default().fg(Color::Green)), + Span::raw("source file matches HEAD — no uncommitted changes"), + ]), + ]; + } + let separator = "─".repeat(usize::from(width.max(8)).saturating_sub(2)); + let mut lines = vec![header, Line::default()]; + let mut old_line = 0usize; + let mut new_line = 0usize; + for raw in diff.lines() { + if raw.starts_with("@@") { + if let Some((old_start, new_start)) = parse_hunk_header(raw) { + old_line = old_start; + new_line = new_start; + } + lines.push(Line::from(Span::styled( + separator.clone(), + Style::default().fg(Color::DarkGray), + ))); + lines.push(diff_line_colored(raw)); + continue; + } + if raw.starts_with("+++") + || raw.starts_with("---") + || raw.starts_with("diff ") + || raw.starts_with("index ") + || raw.starts_with('\\') + { + lines.push(diff_line_colored(raw)); + continue; + } + lines.push(numbered_diff_row(raw, &mut old_line, &mut new_line)); + } + lines +} + +/// One diff content row with its old/new number gutter, advancing the +/// counters by the row's origin. +fn numbered_diff_row(raw: &str, old_line: &mut usize, new_line: &mut usize) -> Line<'static> { + let gutter = match raw.chars().next() { + Some('+') => { + let gutter = format!("{:>4} {:>4} ", "", new_line); + *new_line += 1; + gutter + } + Some('-') => { + let gutter = format!("{:>4} {:>4} ", old_line, ""); + *old_line += 1; + gutter + } + _ => { + let gutter = format!("{old_line:>4} {new_line:>4} "); + *old_line += 1; + *new_line += 1; + gutter + } + }; + let mut spans = vec![Span::styled(gutter, Style::default().fg(Color::DarkGray))]; + spans.extend(diff_line_colored(raw).spans); + Line::from(spans) +} + +/// Parses `@@ -35,7 +36,8 @@ …` into the starting old and new line numbers. +fn parse_hunk_header(raw: &str) -> Option<(usize, usize)> { + let mut fields = raw.split_whitespace(); + let _ = fields.next()?; + let old_start = fields + .next()? + .trim_start_matches('-') + .split(',') + .next()? + .parse() + .ok()?; + let new_start = fields + .next()? + .trim_start_matches('+') + .split(',') + .next()? + .parse() + .ok()?; + Some((old_start, new_start)) +} + +/// Source line (new file) per rendered diff row: parsed from the number +/// gutter each row carries, with wrap continuations inheriting their line. +fn diff_line_map(lines: &[Line<'_>]) -> Vec> { + let mut map = Vec::with_capacity(lines.len()); + let mut last: Option = None; + for line in lines { + let first = line.spans.first().map_or("", |span| span.content.as_ref()); + let value = if first.trim_start().starts_with('↪') { + last + } else { + parse_gutter_new_line(first) + }; + map.push(value); + last = value; + } + map +} + +#[cfg(test)] +pub(super) fn diff_line_map_for_test(lines: &[Line<'_>]) -> Vec> { + diff_line_map(lines) +} + +fn parse_gutter_new_line(gutter: &str) -> Option { + let columns: Vec = gutter.chars().collect(); + if columns.len() < 10 { + return None; + } + columns[5..9].iter().collect::().trim().parse().ok() +} + +/// Browser links per rendered row: a row containing a commit sha links to +/// the repo's commit page. Wrap continuations inherit no link. Only https +/// sources are linkable. +fn commit_links(lines: &[Line<'_>], source_uri: &str) -> Vec> { + let web = source_uri.trim_end_matches(".git"); + if !web.starts_with("https://") { + return vec![None; lines.len()]; + } lines + .iter() + .map(|line| { + let text: String = line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + find_commit_sha(&text).map(|sha| format!("{web}/commit/{sha}")) + }) + .collect() } -fn diff_lines(artifact: &ArtifactView) -> Vec> { +/// First token that looks like a git sha: 7-40 hex chars including a digit. +fn find_commit_sha(text: &str) -> Option<&str> { + text.split(|character: char| !character.is_ascii_hexdigit()) + .find(|token| { + (7..=40).contains(&token.len()) + && token.chars().any(|character| character.is_ascii_digit()) + }) +} + +/// Recent jj changes for the repository detail, beside the git log. +fn jj_log_lines(module: &ModuleView) -> Vec> { + let Some(repo) = module.local_path.as_ref() else { + return Vec::new(); + }; + if !repo.join(".jj").is_dir() { + return Vec::new(); + } + let output = std::process::Command::new("jj") + .args([ + "--ignore-working-copy", + "log", + "-n", + "8", + "--no-graph", + "-T", + "change_id.short(8) ++ \" \" ++ if(local_bookmarks, local_bookmarks.join(\",\") ++ \" \", \"\") ++ description.first_line() ++ \"\\n\"", + ]) + .current_dir(repo) + .output(); + let Ok(output) = output else { + return Vec::new(); + }; + if !output.status.success() { + return Vec::new(); + } let mut lines = vec![ + Line::from(""), Line::from(Span::styled( - "Diff", + "Recent changes (jj)", Style::default().add_modifier(Modifier::BOLD), )), - Line::from("vs deployed and source-at-deploy are computed by the dashboard route today"), - Line::from( - "TUI keeps this tab lazy and off the scan path; target reads remain a follow-up seam", - ), - Line::from(""), ]; - lines.extend( - artifact - .raw_source - .lines() - .take(30) - .map(|line| Line::from(format!(" {line}"))), - ); + for row in String::from_utf8_lossy(&output.stdout).lines() { + let (change, rest) = row.split_at(row.len().min(8)); + lines.push(Line::from(vec![ + Span::styled(change.to_string(), Style::default().fg(Color::Magenta)), + Span::raw(rest.to_string()), + ])); + } + lines +} + +/// Opens a URL with the platform opener, detached from the TUI. +fn open_in_browser(url: &str) -> bool { + let opener = if cfg!(target_os = "macos") { + "open" + } else { + "xdg-open" + }; + std::process::Command::new(opener) + .arg(url) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .is_ok() +} + +/// The Deployments block of the provenance view: per-target verification +/// badges with per-harness rows. +fn deployment_lines(groups: &[commands::view::DeployGroup]) -> Vec> { + let mut lines = vec![Line::from(Span::styled( + "Deployments", + Style::default().add_modifier(Modifier::BOLD), + ))]; + if groups.is_empty() { + lines.push(Line::from(Span::styled( + "not deployed anywhere — D deploys it to a target", + Style::default().fg(Color::DarkGray), + ))); + } + for group in groups { + let all_verified = group.verified == group.total; + lines.push(Line::from(vec![ + Span::styled( + if all_verified { "✓ " } else { "✗ " }, + Style::default().fg(if all_verified { + Color::Green + } else { + Color::Red + }), + ), + Span::styled( + group.target.clone(), + Style::default().add_modifier(Modifier::BOLD), + ), + Span::styled( + format!(" {}/{} verified", group.verified, group.total), + Style::default().fg(Color::DarkGray), + ), + ])); + for harness in &group.harnesses { + let (badge, style) = if harness.verified { + ("✓", Style::default().fg(Color::Green)) + } else { + ("✗ DRIFT", Style::default().fg(Color::Red)) + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + format!("{:<12}", harness.harness), + Style::default().fg(Color::Cyan), + ), + Span::styled(format!("{badge:<8}"), style), + Span::styled( + harness.deployed_path.clone(), + Style::default().fg(Color::DarkGray), + ), + ])); + } + } lines } -fn frontmatter_lines(artifact: &ArtifactView) -> Vec> { +/// Row offsets of hunk headers within rendered diff lines. +fn hunk_offsets(lines: &[Line<'_>]) -> Vec { + lines + .iter() + .enumerate() + .filter(|(_, line)| { + line.spans + .first() + .is_some_and(|span| span.content.starts_with("@@")) + }) + .map(|(index, _)| index) + .collect() +} + +fn diff_line_colored(line: &str) -> Line<'static> { + let style = if line.starts_with("+++") || line.starts_with("---") { + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::BOLD) + } else if line.starts_with('+') { + Style::default().fg(Color::Green) + } else if line.starts_with('-') { + Style::default().fg(Color::Red) + } else if line.starts_with("@@") { + Style::default().fg(Color::Cyan) + } else if line.starts_with("diff ") || line.starts_with("index ") { + Style::default().fg(Color::DarkGray) + } else { + Style::default() + }; + Line::from(Span::styled(line.to_string(), style)) +} + +fn frontmatter_lines(artifact: &ArtifactView, width: u16) -> Vec> { if artifact.metadata.is_empty() { return vec![Line::from("no frontmatter metadata")]; } - artifact + let lines = artifact .metadata .iter() .map(|(key, value)| { @@ -2361,12 +4543,20 @@ fn frontmatter_lines(artifact: &ArtifactView) -> Vec> { Span::raw(value.clone()), ]) }) - .collect() + .collect(); + // Values wrap within the value column, never back to column zero. + expand_gutter_wrapped(lines, 18, usize::from(width)) } fn history_lines(artifact: &ArtifactView) -> Vec> { if artifact.git_log.is_empty() { - return vec![Line::from("no git history")]; + return vec![ + Line::from("no git history for this file"), + Line::from(Span::styled( + "o opens gitui on the repository", + Style::default().fg(Color::DarkGray), + )), + ]; } let mut lines = Vec::new(); for commit in &artifact.git_log { @@ -2394,33 +4584,6 @@ fn history_lines(artifact: &ArtifactView) -> Vec> { } lines } - -fn companion_lines(artifact: &ArtifactView) -> Vec> { - if artifact.companions.is_empty() { - return vec![Line::from("no companions")]; - } - let mut lines = Vec::new(); - for companion in &artifact.companions { - lines.push(Line::from(Span::styled( - companion.name.clone(), - Style::default().add_modifier(Modifier::BOLD), - ))); - lines.push(Line::from(format!("path: {}", companion.relative_path))); - if !companion.description.is_empty() { - lines.push(Line::from(companion.description.clone())); - } - lines.push(Line::from("")); - lines.extend( - companion - .content_body - .lines() - .map(|line| Line::from(line.to_string())), - ); - lines.push(Line::from("")); - } - lines -} - fn copy_to_pbcopy(text: &str) -> bool { let Ok(mut child) = Command::new("pbcopy") .stdin(Stdio::piped()) diff --git a/src/tui/components/preview.rs b/src/tui/components/preview.rs index 97c500a..4b061c9 100644 --- a/src/tui/components/preview.rs +++ b/src/tui/components/preview.rs @@ -2,59 +2,93 @@ use ratatui::{ Frame, layout::Rect, style::{Color, Modifier, Style}, - text::{Line, Span}, + text::{Line, Span, Text}, widgets::{Block, Borders, Paragraph, Wrap}, }; use commands::view::ArtifactView; -/// Approximate the number of rows the body occupies when word-wrapped to -/// `width`, so the scroll offset can be clamped to a reachable bottom. Counts -/// each source line as at least one row plus a row per `width` characters -/// beyond the first; wide glyphs are treated as one column (close enough to -/// keep the last line reachable without pulling in a unicode-width dependency). -fn wrapped_line_count(body: &str, width: u16) -> u16 { +use super::super::app::DetailTab; + +/// Display rows the lines occupy when word-wrapped to `width`. Counts each +/// line as at least one row plus one per full width beyond it; wide glyphs +/// count as one column, close enough to keep the last line reachable. +pub(in super::super) fn wrapped_rows(lines: &[Line<'_>], width: u16) -> usize { if width == 0 { - return u16::try_from(body.lines().count()).unwrap_or(u16::MAX); + return lines.len(); } let width = usize::from(width); - let mut rows: usize = 0; - for line in body.lines() { - let chars = line.chars().count().max(1); - rows = rows.saturating_add(chars.div_ceil(width)); - } - u16::try_from(rows).unwrap_or(u16::MAX) + lines + .iter() + .map(|line| line.width().max(1).div_ceil(width)) + .sum() +} + +/// Rendered lines for one (tab, width) combination, rebuilt only when either +/// changes so scrolling a large file stays cheap. +#[derive(Debug, Clone)] +struct PreviewPane { + tab: DetailTab, + width: u16, + lines: Vec>, + windowed: bool, } -/// Full-window scrollable view of a single artifact's body. Opened from the -/// artifacts pane with Enter, so a skill's full content is readable instead of -/// clipped into a quarter-pane detail column. +/// Full-window scrollable view of the selected artifact. Opened from the +/// detail pane with Enter; shows the same rich tabs as the pane (digits +/// switch), so zooming never loses highlighting or layout. #[derive(Debug, Clone)] pub struct ArtifactPreview { - title: String, - body: String, + artifact: ArtifactView, scroll: u16, + pane: Option, } impl ArtifactPreview { #[must_use] pub fn from_artifact(artifact: &ArtifactView) -> Self { - let body = if artifact.content_body.is_empty() { - artifact.content_preview.clone() - } else { - artifact.content_body.clone() - }; - let title = format!( - " {} · {} · {} ", - artifact.name, artifact.kind, artifact.relative_path - ); Self { - title, - body, + artifact: artifact.clone(), scroll: 0, + pane: None, } } + #[must_use] + pub fn artifact(&self) -> &ArtifactView { + &self.artifact + } + + #[must_use] + pub fn scroll(&self) -> u16 { + self.scroll + } + + #[must_use] + pub fn needs_rebuild(&self, tab: DetailTab, width: u16) -> bool { + self.pane + .as_ref() + .is_none_or(|pane| pane.tab != tab || pane.width != width) + } + + pub fn set_lines( + &mut self, + tab: DetailTab, + width: u16, + lines: Vec>, + windowed: bool, + ) { + if self.pane.as_ref().is_some_and(|pane| pane.tab != tab) { + self.scroll = 0; + } + self.pane = Some(PreviewPane { + tab, + width, + lines, + windowed, + }); + } + pub fn scroll_down(&mut self, amount: u16) { self.scroll = self.scroll.saturating_add(amount); } @@ -71,38 +105,66 @@ impl ArtifactPreview { self.scroll = u16::MAX; } - /// Render takes `&mut self` so the scroll offset can be clamped against the - /// real wrapped line count at the current width — the only place the true - /// content height is known. pub fn render(&mut self, frame: &mut Frame<'_>, area: Rect) { + let tab_label = self + .pane + .as_ref() + .map_or("Preview", |pane| pane.tab.label()); + let title = format!( + " {} · {} · {} ", + self.artifact.name, tab_label, self.artifact.relative_path + ); let block = Block::default() - .title(self.title.as_str()) + .title(title) .title_bottom(Line::from(Span::styled( - " j/k · ␣/b page · g/G ends · Esc close ", + " 1-6 tabs · j/k · ␣/b page · g/G ends · Esc close ", Style::default().fg(Color::DarkGray), ))) .borders(Borders::ALL) .border_style(Style::default().fg(Color::Cyan)); + let inner = block.inner(area); + let viewport = usize::from(inner.height.max(1)); - let inner_width = area.width.saturating_sub(2); - let inner_height = area.height.saturating_sub(2); - - let total = wrapped_line_count(&self.body, inner_width); - let max_scroll = total.saturating_sub(inner_height); + let Some(pane) = &self.pane else { + frame.render_widget(Paragraph::new("building preview...").block(block), area); + return; + }; + // Wrapped content occupies more display rows than logical lines; + // clamp against the wrapped estimate or the tail becomes unreachable. + let total = if pane.windowed { + pane.lines.len() + } else { + wrapped_rows(&pane.lines, inner.width) + }; + let max_scroll = u16::try_from(total.saturating_sub(viewport)).unwrap_or(u16::MAX); if self.scroll > max_scroll { self.scroll = max_scroll; } + let position = Line::from(Span::styled( + format!(" {}/{} ", self.scroll.saturating_add(1), total.max(1)), + Style::default().fg(Color::Gray).add_modifier(Modifier::DIM), + )); - let paragraph = Paragraph::new(self.body.as_str()).wrap(Wrap { trim: false }); - - frame.render_widget( - paragraph - .block(block.title_top(Line::from(Span::styled( - format!(" {}/{} ", self.scroll.saturating_add(1), total.max(1)), - Style::default().fg(Color::Gray).add_modifier(Modifier::DIM), - )))) - .scroll((self.scroll, 0)), - area, - ); + if pane.windowed { + let window: Vec> = pane + .lines + .iter() + .skip(usize::from(self.scroll)) + .take(viewport) + .cloned() + .collect(); + frame.render_widget( + Paragraph::new(Text::from(window)).block(block.title_top(position)), + area, + ); + } else { + frame.render_widget( + Paragraph::new(Text::from(pane.lines.clone())) + .wrap(Wrap { trim: false }) + .scroll((self.scroll, 0)) + .block(block.title_top(position)), + area, + ); + } } } diff --git a/src/tui/event.rs b/src/tui/event.rs index cc9ceba..bc85565 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -1,8 +1,24 @@ -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind}; use super::app::App; +pub fn handle_mouse(app: &mut App, mouse: MouseEvent) { + if app.modal_blocks_mouse() { + return; + } + match mouse.kind { + MouseEventKind::Down(crossterm::event::MouseButton::Left) => { + app.clear_toast(); + app.mouse_click(mouse.column, mouse.row); + } + MouseEventKind::ScrollDown => app.mouse_scroll(mouse.column, mouse.row, true), + MouseEventKind::ScrollUp => app.mouse_scroll(mouse.column, mouse.row, false), + _ => {} + } +} + pub fn handle_key(app: &mut App, key: KeyEvent) { + app.clear_toast(); if app.is_preview_open() { handle_preview_key(app, key); return; @@ -22,6 +38,14 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { app.comment_prompt_key(key); return; } + if app.is_deploy_picker_open() { + app.deploy_picker_key(key); + return; + } + if app.is_launch_picker_open() { + app.launch_picker_key(key); + return; + } if app.is_search_input_active() && matches!( key.code, @@ -31,6 +55,23 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { app.search_input_key(key); return; } + if app.is_list_filter_typing() + && matches!( + key.code, + KeyCode::Char(_) | KeyCode::Backspace | KeyCode::Esc | KeyCode::Enter + ) + { + app.list_filter_key(key); + return; + } + // Digits always address the numbered detail tabs; sections are reached + // by navigation, the palette, and the letter shortcuts (t/h/c/m) shown + // in the Sections column. + if let KeyCode::Char(digit @ '1'..='6') = key.code { + let index = usize::from(digit as u8 - b'1'); + app.set_detail_tab(super::app::DetailTab::ALL[index]); + return; + } if app.has_section_digit_shortcuts() && let KeyCode::Char(character) = key.code && app.set_section_by_shortcut(character) @@ -38,13 +79,16 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { return; } + if !matches!(key.code, KeyCode::Char('q') | KeyCode::Esc) { + app.disarm_quit(); + } match key.code { - KeyCode::Esc | KeyCode::Char('q') => app.request_quit(), + KeyCode::Esc => app.escape(), + KeyCode::Char('q') => app.request_quit(), KeyCode::Char('?') | KeyCode::F(1) => app.toggle_help(), KeyCode::Char(':') => app.open_palette(), - KeyCode::Char('/') => { - app.set_section_by_number(9); - } + KeyCode::Char('/') => app.begin_list_filter(), + KeyCode::Char('!') => app.toggle_problems_only(), KeyCode::Char('r') => app.refresh(), KeyCode::Char('Y') => app.copy_tuicr_review(), KeyCode::Char('y') => app.copy_selected(), @@ -56,6 +100,13 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { KeyCode::Char('p') => app.set_detail_tab(super::app::DetailTab::Preview), KeyCode::Char('c') => app.set_detail_tab(super::app::DetailTab::Code), KeyCode::Char('d') => app.set_detail_tab(super::app::DetailTab::Diff), + KeyCode::Char('v') => app.set_detail_tab(super::app::DetailTab::Provenance), + KeyCode::Char('f') => app.set_detail_tab(super::app::DetailTab::Frontmatter), + KeyCode::Char('i') => app.set_detail_tab(super::app::DetailTab::History), + KeyCode::Char('o') => app.open_repo_tool(false), + KeyCode::Char('O') => app.open_repo_tool(true), + KeyCode::Char('D') => app.open_deploy_picker(), + KeyCode::Char('L') => app.launch_harness(), _ => app.focused_key(key), } } @@ -69,6 +120,16 @@ fn handle_preview_key(app: &mut App, key: KeyEvent) { KeyCode::PageUp | KeyCode::Char('b') => app.preview_scroll_up(10), KeyCode::Home | KeyCode::Char('g') => app.preview_scroll_to_top(), KeyCode::End | KeyCode::Char('G') => app.preview_scroll_to_bottom(), + KeyCode::Char(digit @ '1'..='6') => { + let index = usize::from(digit as u8 - b'1'); + app.set_detail_tab(super::app::DetailTab::ALL[index]); + } + KeyCode::Char('p') => app.set_detail_tab(super::app::DetailTab::Preview), + KeyCode::Char('c') => app.set_detail_tab(super::app::DetailTab::Code), + KeyCode::Char('d') => app.set_detail_tab(super::app::DetailTab::Diff), + KeyCode::Char('v') => app.set_detail_tab(super::app::DetailTab::Provenance), + KeyCode::Char('f') => app.set_detail_tab(super::app::DetailTab::Frontmatter), + KeyCode::Char('i') => app.set_detail_tab(super::app::DetailTab::History), _ => {} } } diff --git a/src/tui/glow_style.json b/src/tui/glow_style.json new file mode 100644 index 0000000..61bf00e --- /dev/null +++ b/src/tui/glow_style.json @@ -0,0 +1,208 @@ +{ + "document": { + "block_prefix": "", + "block_suffix": "\n", + "color": "252", + "margin": 0 + }, + "block_quote": { + "indent": 1, + "indent_token": "\u2503 ", + "color": "109", + "italic": true + }, + "paragraph": {}, + "list": { + "level_indent": 2 + }, + "heading": { + "block_suffix": "\n", + "color": "39", + "bold": true + }, + "h1": { + "prefix": " ", + "suffix": " ", + "color": "228", + "background_color": "63", + "bold": true + }, + "h2": { + "prefix": "", + "color": "45", + "bold": true, + "underline": true + }, + "h3": { + "prefix": "", + "color": "74", + "bold": true + }, + "h4": { + "prefix": "", + "color": "30", + "bold": true + }, + "h5": { + "prefix": "", + "color": "66", + "bold": true + }, + "h6": { + "prefix": "", + "color": "35", + "bold": false + }, + "text": {}, + "strikethrough": { + "crossed_out": true + }, + "emph": { + "italic": true, + "color": "252" + }, + "strong": { + "bold": true, + "color": "229" + }, + "hr": { + "color": "240", + "format": "\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n" + }, + "item": { + "block_prefix": "\u2022 ", + "color": "252" + }, + "enumeration": { + "block_prefix": ". ", + "color": "252" + }, + "task": { + "ticked": "[\u2713] ", + "unticked": "[ ] " + }, + "link": { + "color": "32", + "underline": true + }, + "link_text": { + "color": "45", + "bold": true + }, + "image": { + "color": "212", + "underline": true + }, + "image_text": { + "color": "243", + "format": "Image: {{.text}} \u2192" + }, + "code": { + "prefix": "\u00a0", + "suffix": "\u00a0", + "color": "203", + "background_color": "236" + }, + "code_block": { + "color": "244", + "margin": 2, + "chroma": { + "text": { + "color": "#C4C4C4" + }, + "error": { + "color": "#F1F1F1", + "background_color": "#F05B5B" + }, + "comment": { + "color": "#676767" + }, + "comment_preproc": { + "color": "#FF875F" + }, + "keyword": { + "color": "#00AAFF" + }, + "keyword_reserved": { + "color": "#FF5FD2" + }, + "keyword_namespace": { + "color": "#FF5F87" + }, + "keyword_type": { + "color": "#6E6ED8" + }, + "operator": { + "color": "#EF8080" + }, + "punctuation": { + "color": "#E8E8A8" + }, + "name": { + "color": "#C4C4C4" + }, + "name_builtin": { + "color": "#FF8EC7" + }, + "name_tag": { + "color": "#B083EA" + }, + "name_attribute": { + "color": "#7A7AE6" + }, + "name_class": { + "color": "#F1F1F1", + "underline": true, + "bold": true + }, + "name_constant": {}, + "name_decorator": { + "color": "#FFFF87" + }, + "name_exception": {}, + "name_function": { + "color": "#00D787" + }, + "name_other": {}, + "literal": {}, + "literal_number": { + "color": "#6EEFC0" + }, + "literal_date": {}, + "literal_string": { + "color": "#C69669" + }, + "literal_string_escape": { + "color": "#AFFFD7" + }, + "generic_deleted": { + "color": "#FD5B5B" + }, + "generic_emph": { + "italic": true + }, + "generic_inserted": { + "color": "#00D787" + }, + "generic_strong": { + "bold": true + }, + "generic_subheading": { + "color": "#777777" + }, + "background": { + "background_color": "#373737" + } + } + }, + "table": { + "color": "252" + }, + "definition_list": {}, + "definition_term": {}, + "definition_description": { + "block_prefix": "\n\ud83e\udc36 " + }, + "html_block": {}, + "html_span": {} +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 2cdd9fd..de2ef6b 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -2,6 +2,7 @@ pub mod app; pub mod components; pub mod event; mod rich; +mod word_wrap; use std::{ io::{self, Stdout}, @@ -11,12 +12,14 @@ use std::{ use crossterm::{ cursor::{Hide, Show}, - event as terminal_event, execute, + event as terminal_event, + event::{DisableMouseCapture, EnableMouseCapture}, + execute, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; -use ratatui::{Terminal, backend::CrosstermBackend}; +use ratatui::{Terminal, backend::CrosstermBackend, backend::TestBackend}; -use app::App; +use app::{App, DetailTab}; #[cfg(test)] mod tests; @@ -33,6 +36,74 @@ pub fn run() -> i32 { } } +/// Render a single frame to plain text on stdout, for headless inspection of the +/// layout at a given size and view. Waits for the background scan to deliver real +/// data before drawing. This is the verification tool: run it, read the output. +pub fn run_snapshot( + width: u16, + height: u16, + section: Option, + tab: Option<&str>, + drill: u8, + row: usize, +) -> i32 { + let mut app = App::load(PathBuf::from(".")); + for _ in 0..3000 { + app.poll_scan(); + if !app.scan_pending() { + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + if let Some(number) = section { + app.set_section_by_number(number); + } + for step in 0..drill { + app.drill_or_expand(); + if step == 0 { + for _ in 0..row { + app.move_list_selection(1); + } + } + } + if let Some(detail_tab) = tab.and_then(detail_tab_from_name) { + app.set_detail_tab(detail_tab); + } + let backend = TestBackend::new(width, height); + let mut terminal = match Terminal::new(backend) { + Ok(terminal) => terminal, + Err(error) => { + eprintln!("fatal: {error}"); + return 2; + } + }; + if let Err(error) = terminal.draw(|frame| app.render(frame)) { + eprintln!("fatal: {error}"); + return 2; + } + let buffer = terminal.backend().buffer(); + for y in 0..buffer.area.height { + let mut line = String::new(); + for x in 0..buffer.area.width { + line.push_str(buffer[(x, y)].symbol()); + } + println!("{}", line.trim_end()); + } + 0 +} + +fn detail_tab_from_name(name: &str) -> Option { + match name.to_ascii_lowercase().as_str() { + "preview" => Some(DetailTab::Preview), + "code" => Some(DetailTab::Code), + "diff" => Some(DetailTab::Diff), + "provenance" => Some(DetailTab::Provenance), + "frontmatter" => Some(DetailTab::Frontmatter), + "history" => Some(DetailTab::History), + _ => None, + } +} + fn launch() -> Result<(), Box> { let mut app = App::load(PathBuf::from(".")); let mut terminal = setup_terminal()?; @@ -46,20 +117,38 @@ fn launch() -> Result<(), Box> { fn setup_terminal() -> io::Result { enable_raw_mode()?; let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen, Hide)?; - let backend = CrosstermBackend::new(stdout); - Terminal::new(backend) + if let Err(error) = execute!(stdout, EnterAlternateScreen, EnableMouseCapture, Hide) { + let _ = disable_raw_mode(); + return Err(error); + } + match Terminal::new(CrosstermBackend::new(stdout)) { + Ok(terminal) => Ok(terminal), + Err(error) => { + restore_terminal_without_backend(); + Err(error) + } + } } fn restore_terminal(terminal: &mut TuiTerminal) { let _ = disable_raw_mode(); - let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen, Show); + let _ = execute!( + terminal.backend_mut(), + DisableMouseCapture, + LeaveAlternateScreen, + Show + ); let _ = terminal.show_cursor(); } fn restore_terminal_without_backend() { let _ = disable_raw_mode(); - let _ = execute!(io::stdout(), LeaveAlternateScreen, Show); + let _ = execute!( + io::stdout(), + DisableMouseCapture, + LeaveAlternateScreen, + Show + ); } fn install_panic_hook() { @@ -74,11 +163,43 @@ fn event_loop(terminal: &mut TuiTerminal, app: &mut App) -> Result<(), Box event::handle_key(app, key), + terminal_event::Event::Mouse(mouse) => event::handle_mouse(app, mouse), + _ => {} + } } + if let Some(command) = app.take_external() { + run_external_tool(terminal, app, &command)?; + } + } + Ok(()) +} + +/// Suspends the TUI, runs an external terminal tool (gitui/jjui) in the given +/// directory with the real terminal, and resumes when it exits. +fn run_external_tool( + terminal: &mut TuiTerminal, + app: &mut App, + command: &app::ExternalCommand, +) -> Result<(), Box> { + let program = &command.program; + restore_terminal(terminal); + let status = std::process::Command::new(program) + .args(&command.args) + .current_dir(&command.directory) + .status(); + *terminal = setup_terminal()?; + terminal.clear()?; + // The tool may have committed, amended, or touched files: rescan so VCS + // state, diffs, and history reflect what it left behind. Force it — a + // scan already in flight predates whatever the tool changed. + app.force_refresh(); + match status { + Ok(status) if status.success() => {} + Ok(status) => app.set_toast(format!("{program} exited with {status}")), + Err(error) => app.set_toast(format!("could not launch {program}: {error}")), } Ok(()) } diff --git a/src/tui/rich.rs b/src/tui/rich.rs index 4cd1e17..dd1cdb6 100644 --- a/src/tui/rich.rs +++ b/src/tui/rich.rs @@ -16,24 +16,73 @@ use syntect::{ parsing::SyntaxSet, }; +/// Markdown preview: prose renders through glow, fenced code blocks through +/// syntect. Glamour leaves fences without a language tag uncolored, so code +/// gets the same highlighter as the Code tab instead. pub fn render_markdown_with_glow(body: &str, width: u16) -> Option>> { if body.is_empty() || width == 0 { return None; } + let segments = split_fences(body); + // Reference-link definitions may live in a different segment than their + // uses; hand every prose segment the full definition list so links keep + // resolving. Glamour consumes definitions without rendering them. + let definitions = if segments.len() > 1 { + reference_definitions(body) + } else { + String::new() + }; + let mut lines: Vec> = Vec::new(); + for segment in segments { + match segment { + Segment::Prose(text) => { + if text.trim().is_empty() { + continue; + } + let input = if definitions.is_empty() { + text + } else { + format!("{text}\n{definitions}") + }; + lines.extend(glow_lines(&input, width)?); + } + Segment::Code { language, source } => { + if lines.last().is_some_and(|line| line.width() > 0) { + lines.push(Line::default()); + } + lines.extend(highlight_fence(&language, &source)); + lines.push(Line::default()); + } + } + } + Some(lines) +} + +fn glow_lines(text: &str, width: u16) -> Option>> { + let style = glow_style_path()?; let mut child = Command::new("glow") - .args(["-s", "dark", "-w", &width.to_string()]) + .args(["-s", &style, "-w", &width.to_string()]) + // glow sees a pipe, not a terminal, and silently drops all color + // without these. + .env("CLICOLOR_FORCE", "1") + .env("COLORTERM", "truecolor") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::null()) .spawn() .ok()?; + // Feed stdin from a separate thread: writing a body larger than the OS + // pipe buffer while glow's stdout pipe fills would deadlock both sides. let mut stdin = child.stdin.take()?; - stdin.write_all(body.as_bytes()).ok()?; - drop(stdin); - - let output = child.wait_with_output().ok()?; + let body = text.as_bytes().to_vec(); + let writer = std::thread::spawn(move || { + let _ = stdin.write_all(&body); + }); + let output = child.wait_with_output().ok(); + let _ = writer.join(); + let output = output?; if !output.status.success() { return None; } @@ -42,6 +91,134 @@ pub fn render_markdown_with_glow(body: &str, width: u16) -> Option String { + body.lines() + .filter(|line| { + let trimmed = line.trim_start(); + trimmed.starts_with('[') && trimmed.contains("]:") + }) + .collect::>() + .join("\n") +} + +/// Splits a markdown body at backtick fences. The fence lines are dropped; +/// the opening fence's info string becomes the code language. Per `CommonMark`, +/// a line indented four or more columns is a literal, never a fence; a block +/// closes only on a fence at least as long as the one that opened it, so a +/// four-backtick block can contain triple-backtick examples. An unclosed +/// fence keeps the rest of the body as `code`. +fn split_fences(body: &str) -> Vec { + let mut segments = Vec::new(); + let mut current = String::new(); + let mut open_fence: Option<(String, usize)> = None; + for line in body.lines() { + let trimmed = line.trim_start(); + let indent = line.chars().count() - trimmed.chars().count(); + let backticks = trimmed + .chars() + .take_while(|&character| character == '`') + .count(); + let is_fence_line = backticks >= 3 && indent < 4; + match (&open_fence, is_fence_line) { + (Some((_, opened_with)), true) + if backticks >= *opened_with && trimmed[backticks..].trim().is_empty() => + { + let (language, _) = open_fence.take().expect("fence is open"); + segments.push(Segment::Code { + language, + source: std::mem::take(&mut current), + }); + } + (None, true) => { + if !current.is_empty() { + segments.push(Segment::Prose(std::mem::take(&mut current))); + } + open_fence = Some((trimmed[backticks..].trim().to_string(), backticks)); + } + _ => { + current.push_str(line); + current.push('\n'); + } + } + } + if !current.is_empty() { + segments.push(match open_fence { + Some((language, _)) => Segment::Code { + language, + source: current, + }, + None => Segment::Prose(current), + }); + } + segments +} + +/// Highlights one fenced block with the Code tab's engine, indented by the +/// two columns glamour uses for code-block margins. +fn highlight_fence(language: &str, source: &str) -> Vec> { + let (syntax_set, theme_set) = syntax_assets(); + let syntax = syntax_set + .find_syntax_by_token(language) + .unwrap_or_else(|| syntax_set.find_syntax_plain_text()); + let Some(theme) = theme_set + .themes + .get("base16-ocean.dark") + .or_else(|| theme_set.themes.values().next()) + else { + return source + .lines() + .map(|line| Line::from(format!(" {line}"))) + .collect(); + }; + let mut highlighter = HighlightLines::new(syntax, theme); + source + .lines() + .map(|line| { + let mut spans = vec![Span::raw(" ")]; + match highlighter.highlight_line(line, syntax_set) { + Ok(ranges) => { + spans.extend( + ranges + .into_iter() + .filter(|(_, text)| !text.is_empty()) + .map(|(style, text)| Span::styled(text.to_string(), tui_style(style))), + ); + } + Err(_) => spans.push(Span::raw(line.to_string())), + } + Line::from(spans) + }) + .collect() +} + +/// Glamour's built-in styles indent the whole document by a margin, which +/// floats the body off the pane's left border. Ship a dark style with the +/// document margin zeroed and hand it to glow as a style file. +fn glow_style_path() -> Option { + static STYLE_PATH: OnceLock> = OnceLock::new(); + STYLE_PATH + .get_or_init(|| { + let user = std::env::var("USER").unwrap_or_else(|_| "shared".to_string()); + let path = std::env::temp_dir().join(format!("forge-glow-style-{user}.json")); + let staging = std::env::temp_dir().join(format!( + "forge-glow-style-{user}-{}.json.tmp", + std::process::id() + )); + // Write-then-rename keeps concurrent forge processes from ever + // observing a truncated style file. + std::fs::write(&staging, include_str!("glow_style.json")).ok()?; + std::fs::rename(&staging, &path).ok()?; + Some(path.to_string_lossy().into_owned()) + }) + .clone() +} + pub fn highlight_code(path: &str, source: &str) -> Vec> { if source.is_empty() { return vec![Line::from(vec![ @@ -194,4 +371,74 @@ mod tests { let text = lines.iter().map(line_text).collect::>().join("\n"); assert!(text.contains("Title")); } + + #[test] + fn reference_links_resolve_across_fence_segments() { + if Command::new("glow").arg("--version").output().is_err() { + return; + } + + let body = "See [the docs][D].\n\n```sh\necho hi\n```\n\n[D]: https://example.com\n"; + let lines = render_markdown_with_glow(body, 60).expect("glow output"); + let text = lines.iter().map(line_text).collect::>().join("\n"); + assert!(text.contains("https://example.com")); + assert!(!text.contains("[D]")); + } + + #[test] + fn split_fences_separates_code_from_prose() { + let segments = split_fences("intro\n\n```sh\necho hello\n```\n\noutro\n"); + assert_eq!(segments.len(), 3); + let Segment::Code { language, source } = &segments[1] else { + panic!("second segment should be code"); + }; + assert_eq!(language, "sh"); + assert_eq!(source, "echo hello\n"); + let Segment::Prose(outro) = &segments[2] else { + panic!("third segment should be prose"); + }; + assert!(outro.contains("outro")); + } + + #[test] + fn split_fences_ignores_indented_literal_fence() { + let segments = split_fences("prose\n\n ```not-a-fence\n\nmore prose\n"); + assert_eq!(segments.len(), 1); + let Segment::Prose(text) = &segments[0] else { + panic!("all prose"); + }; + assert!(text.contains("```not-a-fence")); + } + + #[test] + fn split_fences_keeps_triple_backticks_inside_longer_fence() { + let segments = split_fences("````markdown\n```sh\necho hi\n```\n````\n"); + assert_eq!(segments.len(), 1); + let Segment::Code { language, source } = &segments[0] else { + panic!("one code segment"); + }; + assert_eq!(language, "markdown"); + assert!(source.contains("```sh")); + assert!(source.contains("echo hi")); + } + + #[test] + fn fenced_code_in_preview_gets_highlight_colors() { + if Command::new("glow").arg("--version").output().is_err() { + return; + } + + let lines = render_markdown_with_glow("text\n\n```rust\nfn main() {}\n```\n", 60) + .expect("glow output"); + let code_line = lines + .iter() + .find(|line| line_text(line).contains("fn main()")) + .expect("code line present"); + assert!( + code_line + .spans + .iter() + .any(|span| span.style != Style::default()) + ); + } } diff --git a/src/tui/tests.rs b/src/tui/tests.rs index 643f388..ada040d 100644 --- a/src/tui/tests.rs +++ b/src/tui/tests.rs @@ -15,11 +15,20 @@ use commands::{ }; use super::{ - app::{App, CommentKind, DetailTab, KEYBINDINGS, Section}, + app::{App, ColumnFocus, CommentKind, DetailTab, KEYBINDINGS, Section}, components::palette::{Palette, PaletteCommand}, event, }; +fn buffer_position(output: &str, needle: &str) -> (u16, u16) { + let byte_index = output.find(needle).expect("needle rendered"); + let cell_index = output[..byte_index].chars().count(); + ( + u16::try_from(cell_index % 120).expect("x fits"), + u16::try_from(cell_index / 120).expect("y fits"), + ) +} + fn fixture_view() -> DashboardView { let mut providers = std::collections::BTreeMap::new(); providers.insert( @@ -63,6 +72,9 @@ fn fixture_view() -> DashboardView { source_uri: "https://github.com/N4M3Z/forge-core".to_string(), is_target: false, artifacts: vec![artifact], + local_path: None, + vcs: None, + git_log: Vec::new(), }, ModuleView { name: "project-target".to_string(), @@ -71,6 +83,9 @@ fn fixture_view() -> DashboardView { source_uri: "https://github.com/N4M3Z/project-target".to_string(), is_target: true, artifacts: Vec::new(), + local_path: None, + vcs: None, + git_log: Vec::new(), }, ], summary: StatusSummary { @@ -105,6 +120,7 @@ fn fixture_view() -> DashboardView { state: "authored".to_string(), source: String::new(), summary: "Context summary".to_string(), + local_path: String::new(), }], } } @@ -223,7 +239,7 @@ fn provenance_and_history_tabs_render_scanned_data() { let provenance = rendered(&mut app); assert!(provenance.contains("target-one")); - assert!(provenance.contains("OK")); + assert!(provenance.contains("1/1 verified")); app.set_detail_tab(DetailTab::History); let history = rendered(&mut app); @@ -269,20 +285,19 @@ fn help_overlay_renders_known_binding_and_quit() { #[test] fn keybindings_table_drives_help_and_hint_row() { - let binding = KEYBINDINGS - .iter() - .flat_map(|(_, bindings)| bindings.iter()) - .find(|(key, _)| *key == "h/j/k/l") - .expect("navigation binding"); - assert_eq!(binding.1, "move, drill, and go back"); - let mut app = fixture_app(); - let hint = rendered(&mut app); - assert!(hint.contains("h/j/k/l move, drill, and go back")); + let help_open = { + event::handle_key(&mut app, key(KeyCode::Char('?'))); + rendered(&mut app) + }; + assert!(help_open.contains("quit")); event::handle_key(&mut app, key(KeyCode::Char('?'))); - let help = rendered(&mut app); - assert!(help.contains("move, drill, and go back")); + app.focus_next(); + let hint = rendered(&mut app); + assert!(hint.contains("/ filter")); + assert!(hint.contains("! problems")); + let _ = KEYBINDINGS; } #[test] @@ -331,29 +346,33 @@ fn unknown_palette_command_sets_error() { } #[test] -fn search_section_list_focus_types_query_before_global_shortcuts() { +fn search_input_mode_is_explicit() { let mut app = fixture_app(); app.set_section_by_number(9); - app.focus_next(); + event::handle_key(&mut app, key(KeyCode::Char('/'))); for character in ['h', 'e', 'l', 'l', 'o'] { event::handle_key(&mut app, key(KeyCode::Char(character))); } - assert_eq!(app.search_query(), "hello"); assert_eq!(app.section(), Section::Search); + + event::handle_key(&mut app, key(KeyCode::Enter)); + event::handle_key(&mut app, key(KeyCode::Char('j'))); + assert_eq!(app.search_query(), "hello"); } #[test] -fn detail_digit_shortcut_selects_tab_without_changing_section() { +fn digits_switch_detail_tabs_from_any_focus() { let mut app = fixture_app(); - app.focus_next(); - app.focus_next(); - event::handle_key(&mut app, key(KeyCode::Char('2'))); + event::handle_key(&mut app, key(KeyCode::Char('3'))); + assert_eq!(app.detail_tab(), DetailTab::Diff); + assert_eq!(app.section(), Section::Overview); + app.focus_next(); + event::handle_key(&mut app, key(KeyCode::Char('2'))); assert_eq!(app.detail_tab(), DetailTab::Code); - assert_eq!(app.section(), Section::Overview); } #[test] @@ -415,6 +434,7 @@ fn rich_detail_caches_are_reused_between_frames() { fn tuicr_digest_exports_line_comments() { let mut app = fixture_app(); app.add_comment_for_test( + "forge-core", "skills/BuildSkill/SKILL.md", 3, CommentKind::Issue, @@ -426,6 +446,195 @@ fn tuicr_digest_exports_line_comments() { assert!(digest.contains("**[ISSUE]** `skills/BuildSkill/SKILL.md:3`")); } +#[test] +fn mouse_click_selects_section_and_focuses() { + let mut app = fixture_app(); + let output = rendered(&mut app); + let (x, y) = buffer_position(&output, "Skills"); + + app.mouse_click(x, y); + + assert_eq!(app.section(), Section::Skills); + assert_eq!(app.focused_column(), ColumnFocus::Sections); +} + +#[test] +fn mouse_click_on_tab_switches_detail_tab() { + let mut app = fixture_app(); + app.set_section_by_number(2); + app.drill_or_expand(); + app.drill_or_expand(); + let output = rendered(&mut app); + let (x, y) = buffer_position(&output, "Diff"); + + app.mouse_click(x, y); + + assert_eq!(app.detail_tab(), DetailTab::Diff); + assert_eq!(app.focused_column(), ColumnFocus::Detail); +} + +#[test] +fn mouse_wheel_scrolls_detail_without_moving_selection() { + let mut app = fixture_app(); + app.set_section_by_number(2); + app.drill_or_expand(); + app.drill_or_expand(); + let output = rendered(&mut app); + let (x, y) = buffer_position(&output, "Preview "); + let selected_before = app.selected_row_for_test(); + + app.mouse_scroll(x, y + 2, true); + + assert_eq!(app.selected_row_for_test(), selected_before); + assert_eq!(app.detail_scroll_for_test(), 3); +} + +#[test] +fn deploy_picker_queues_additive_install_for_selected_module() { + let mut app = fixture_app(); + app.set_section_by_number(2); + app.drill_or_expand(); + + app.open_deploy_picker(); + assert!( + !app.is_deploy_picker_open(), + "fixture module has no local repo" + ); + + app.set_module_local_path_for_test("forge-core", PathBuf::from("/tmp/forge-core")); + app.open_deploy_picker(); + assert!(app.is_deploy_picker_open()); + + event::handle_key(&mut app, key(KeyCode::Enter)); + let command = app.take_external().expect("install queued"); + assert!(command.args.contains(&"install".to_string())); + assert!(command.args.contains(&"--no-prune".to_string())); + assert!(command.args.contains(&"/tmp/forge-core".to_string())); + assert!(command.args.contains(&"--only".to_string())); + assert!(command.args.contains(&"skills/BuildSkill/".to_string())); +} + +#[test] +fn launch_queues_harness_in_module_repo() { + let mut app = fixture_app(); + app.set_section_by_number(2); + app.drill_or_expand(); + app.set_module_local_path_for_test("forge-core", PathBuf::from("/tmp/forge-core")); + + event::handle_key(&mut app, key(KeyCode::Char('L'))); + assert!(app.is_launch_picker_open()); + event::handle_key(&mut app, key(KeyCode::Enter)); + + let command = app.take_external().expect("launch queued"); + assert_eq!(command.directory, PathBuf::from("/tmp/forge-core")); + assert!(command.args.is_empty()); +} + +#[test] +fn in_panel_filter_narrows_and_esc_restores() { + let mut app = fixture_app(); + app.set_section_by_number(2); + + event::handle_key(&mut app, key(KeyCode::Char('/'))); + for character in ['z', 'z'] { + event::handle_key(&mut app, key(KeyCode::Char(character))); + } + let filtered = rendered(&mut app); + assert!(!filtered.contains("BuildSkill")); + assert!(filtered.contains("/zz")); + + event::handle_key(&mut app, key(KeyCode::Esc)); + let restored = rendered(&mut app); + assert!(restored.contains("BuildSkill")); +} + +#[test] +fn problems_only_hides_healthy_rows() { + let mut app = fixture_app(); + app.set_section_by_number(2); + + event::handle_key(&mut app, key(KeyCode::Char('!'))); + let problems = rendered(&mut app); + assert!(!problems.contains("BuildSkill")); + assert!(problems.contains("[!]")); + + event::handle_key(&mut app, key(KeyCode::Char('!'))); + assert!(rendered(&mut app).contains("BuildSkill")); +} + +#[test] +fn overview_inventory_rows_jump_to_sections() { + let mut app = fixture_app(); + app.focus_next(); + + // Summary, Nested view, then Inventory: skills (1), forge-core (1). + app.move_list_selection(1); + app.move_list_selection(1); + event::handle_key(&mut app, key(KeyCode::Enter)); + assert_eq!(app.section(), Section::Skills); + + app.set_section_by_number(1); + app.move_list_selection(1); + event::handle_key(&mut app, key(KeyCode::Enter)); + assert_eq!(app.section(), Section::Search); + let filtered = rendered(&mut app); + assert!(filtered.contains("module: forge-core") || filtered.contains("BuildSkill")); + assert!(filtered.contains("BuildSkill")); +} + +#[test] +fn module_column_shows_on_unselected_rows() { + let mut view = fixture_view(); + let mut second = view.modules[0].artifacts[0].clone(); + second.name = "ZetaSkill".to_string(); + view.modules[0].artifacts.push(second); + let mut app = App::from_view_with_files( + PathBuf::from("."), + Vec::new(), + Vec::new(), + view, + fixture_file_sections(), + ); + app.set_section_by_number(2); + + let output = rendered(&mut app); + // Selection sits on BuildSkill; ZetaSkill's row must still show its module. + let zeta_line = output + .split("ZetaSkill") + .nth(1) + .expect("ZetaSkill rendered"); + assert!(zeta_line[..120].contains("forge-core")); + assert!(output.contains("· 1/2")); +} + +#[test] +fn comment_prompt_opens_from_preview_tab() { + let mut app = fixture_app(); + app.set_section_by_number(2); + app.drill_or_expand(); + app.drill_or_expand(); + assert_eq!(app.detail_tab(), DetailTab::Preview); + + event::handle_key(&mut app, key(KeyCode::Char('m'))); + + assert!(app.is_comment_prompt_open()); + assert_eq!(app.detail_tab(), DetailTab::Code); +} + +#[test] +fn diff_gutter_maps_rows_to_new_file_lines() { + use ratatui::text::{Line, Span}; + let lines = vec![ + Line::from("Diff · uncommitted source changes"), + Line::from(Span::raw(" 35 -removed")), + Line::from(Span::raw(" 37 +added")), + Line::from(Span::raw(" 36 38 context")), + Line::from(Span::raw(" ↪ continuation")), + ]; + let map = super::app::diff_line_map_for_test(&lines); + assert_eq!(map, vec![None, None, Some(37), Some(38), Some(38)]); +} + #[test] fn tuicr_comment_kind_cycles_in_order() { assert_eq!(CommentKind::Issue.next(), CommentKind::Note); diff --git a/src/tui/word_wrap.rs b/src/tui/word_wrap.rs new file mode 100644 index 0000000..3ae265c --- /dev/null +++ b/src/tui/word_wrap.rs @@ -0,0 +1,181 @@ +//! Gutter-preserving line wrapping. Long lines are pre-expanded into visual +//! rows so continuation text aligns after the gutter (line numbers, key +//! columns) with a `↪` marker, instead of sliding under it — the tuicr wrap +//! model. Pre-expansion also makes scroll math exact: one line, one row. + +use ratatui::{ + style::{Color, Style}, + text::{Line, Span}, +}; +use unicode_width::UnicodeWidthChar; + +pub(super) fn expand_gutter_wrapped( + lines: Vec>, + gutter_width: usize, + viewport_width: usize, +) -> Vec> { + let viewport_width = viewport_width.max(1); + let content_width = viewport_width.saturating_sub(gutter_width); + if content_width < 8 { + return lines; + } + let mut expanded = Vec::new(); + for line in lines { + if line.width() <= viewport_width { + expanded.push(line); + continue; + } + let (gutter, content) = split_at_width(line.spans, gutter_width); + for (row_index, row) in wrap_spans(content, content_width).into_iter().enumerate() { + let mut spans = if row_index == 0 { + gutter.clone() + } else { + vec![Span::styled( + format!("{:>width$} ", "↪", width = gutter_width.saturating_sub(1)), + Style::default().fg(Color::DarkGray), + )] + }; + spans.extend(row); + expanded.push(Line::from(spans)); + } + } + expanded +} + +/// Splits spans at a display-column boundary, cutting inside a span when the +/// boundary lands mid-span. +fn split_at_width( + spans: Vec>, + width: usize, +) -> (Vec>, Vec>) { + let mut left = Vec::new(); + let mut right = Vec::new(); + let mut consumed = 0usize; + for span in spans { + if consumed >= width { + right.push(span); + continue; + } + let span_width = span.width(); + if consumed + span_width <= width { + consumed += span_width; + left.push(span); + continue; + } + let available = width - consumed; + let mut taken = 0usize; + let mut boundary = 0usize; + for character in span.content.chars() { + let character_width = character.width().unwrap_or(0); + if taken + character_width > available { + break; + } + taken += character_width; + boundary += character.len_utf8(); + } + let text = span.content.into_owned(); + left.push(Span::styled(text[..boundary].to_string(), span.style)); + right.push(Span::styled(text[boundary..].to_string(), span.style)); + consumed = width; + } + (left, right) +} + +/// Breaks styled spans into rows of at most `width` display columns, +/// preserving each fragment's style. Character wrap, exact by construction. +fn wrap_spans(spans: Vec>, width: usize) -> Vec>> { + let mut rows = Vec::new(); + let mut current: Vec> = Vec::new(); + let mut current_width = 0usize; + for span in spans { + let style = span.style; + let mut rest = span.content.into_owned(); + while !rest.is_empty() { + let available = width.saturating_sub(current_width); + let mut taken_width = 0usize; + let mut boundary = 0usize; + for character in rest.chars() { + let character_width = character.width().unwrap_or(0); + if taken_width + character_width > available { + break; + } + taken_width += character_width; + boundary += character.len_utf8(); + } + if boundary == 0 { + if current.is_empty() { + // A single glyph wider than the row: force it through. + let character = rest.chars().next().expect("rest is non-empty"); + boundary = character.len_utf8(); + current.push(Span::styled(rest[..boundary].to_string(), style)); + rest = rest[boundary..].to_string(); + } + rows.push(std::mem::take(&mut current)); + current_width = 0; + continue; + } + current.push(Span::styled(rest[..boundary].to_string(), style)); + current_width += taken_width; + rest = rest[boundary..].to_string(); + } + } + if !current.is_empty() { + rows.push(current); + } + if rows.is_empty() { + rows.push(Vec::new()); + } + rows +} + +#[cfg(test)] +mod tests { + use super::*; + + fn row_text(line: &Line<'_>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect() + } + + #[test] + fn short_lines_pass_through_unchanged() { + let lines = vec![Line::from(" 12 short line")]; + let expanded = expand_gutter_wrapped(lines.clone(), 5, 40); + assert_eq!(expanded.len(), 1); + assert_eq!(row_text(&expanded[0]), row_text(&lines[0])); + } + + #[test] + fn long_lines_continue_after_the_gutter_with_marker() { + let gutter = " 12 "; + let content = "a".repeat(60); + let lines = vec![Line::from(format!("{gutter}{content}"))]; + let expanded = expand_gutter_wrapped(lines, 5, 30); + assert_eq!(expanded.len(), 3); + assert!(row_text(&expanded[0]).starts_with(" 12 ")); + assert!(row_text(&expanded[1]).starts_with(" ↪ ")); + assert_eq!(row_text(&expanded[1]).chars().count(), 30); + let rejoined: String = expanded + .iter() + .map(|line| row_text(line).chars().skip(5).collect::()) + .collect(); + assert_eq!(rejoined, "a".repeat(60)); + } + + #[test] + fn styles_survive_the_wrap_boundary() { + let styled = Span::styled("b".repeat(40), Style::default().fg(Color::Green)); + let lines = vec![Line::from(vec![Span::raw(" 1 "), styled])]; + let expanded = expand_gutter_wrapped(lines, 4, 24); + assert!(expanded.len() > 1); + for line in &expanded { + assert!( + line.spans + .iter() + .any(|span| span.style.fg == Some(Color::Green)) + ); + } + } +} diff --git a/src/view.rs b/src/view.rs index 3bf4741..b3fb0b6 100644 --- a/src/view.rs +++ b/src/view.rs @@ -31,6 +31,8 @@ pub struct Adr { pub source: String, /// First prose paragraph (Context section when present), for the list preview. pub summary: String, + /// Absolute path of the ADR file on disk, for full-document rendering. + pub local_path: String, } /// One repo's ADRs in the list view: the repo label, its total, and the ADRs @@ -206,6 +208,12 @@ pub struct ModuleView { /// the canon sources discovered through their deployed artifacts. pub is_target: bool, pub artifacts: Vec, + /// Local clone of the module's source repo, when one was discovered. + pub local_path: Option, + /// Repo-level version-control state (branch, ahead/behind, dirty). + pub vcs: Option, + /// Most recent commits across the whole repo. + pub git_log: Vec, } impl ModuleView { @@ -224,6 +232,10 @@ pub struct ArtifactView { pub kind: String, pub module: String, pub relative_path: String, + /// Path of the source file relative to the module repo, when known. Falls + /// back to `relative_path` (the deploy key) for VCS matching — the two + /// diverge once modules live inside a monorepo. + pub source_path: String, pub description: String, pub content_preview: String, pub content_body: String, @@ -246,6 +258,27 @@ pub struct ArtifactView { /// Per-harness and per-model qualifier overrides found in the source tree /// (the model-targeting variants from PROV-0005), empty when none. pub variants: Vec, + /// Version-control state of the artifact's source file, `None` when the + /// module has no local repo. + pub vcs: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct VcsState { + pub branch: String, + pub worktree: WorktreeState, + /// Commits on HEAD not yet on the upstream, and vice versa. Both zero when + /// the branch has no upstream. + pub ahead: usize, + pub behind: usize, + pub jj_colocated: bool, +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +pub enum WorktreeState { + Clean, + Modified, + Untracked, } /// A harness- or model-qualifier override of a base artifact, discovered in the