From 74dee8bca95594fc40aad404ac9b95c9fda07af5 Mon Sep 17 00:00:00 2001 From: Martin Zeman Date: Fri, 10 Jul 2026 01:21:45 +0200 Subject: [PATCH 01/22] feat: TUI snapshot mode, vcs visibility, aligned glow preview - forge tui --snapshot renders one frame headless (width/section/drill/row/tab) - list rows drop the redundant kind; middle column sizes to full row content - vendored zero-margin glamour style so previews sit flush to the pane border - glow output is not re-wrapped by the paragraph, keeping tables aligned - per-artifact version line: branch or jj bookmark, worktree state, last commit --- src/cli/mod.rs | 40 ++++++- src/services/adr.rs | 1 + src/services/discovery.rs | 1 + src/services/mod.rs | 5 + src/services/target.rs | 1 + src/services/vcs.rs | 223 ++++++++++++++++++++++++++++++++++++++ src/tui/app.rs | 145 +++++++++++++++++++------ src/tui/glow_style.json | 191 ++++++++++++++++++++++++++++++++ src/tui/mod.rs | 73 ++++++++++++- src/tui/rich.rs | 17 ++- src/view.rs | 21 ++++ 11 files changed, 680 insertions(+), 38 deletions(-) create mode 100644 src/services/vcs.rs create mode 100644 src/tui/glow_style.json diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 7f79524..387bf42 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 { @@ -375,7 +397,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, diff --git a/src/services/adr.rs b/src/services/adr.rs index b949cbd..c2f64c8 100644 --- a/src/services/adr.rs +++ b/src/services/adr.rs @@ -162,6 +162,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/discovery.rs b/src/services/discovery.rs index 1456597..c4c6762 100644 --- a/src/services/discovery.rs +++ b/src/services/discovery.rs @@ -244,6 +244,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..9b059cd 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; @@ -109,10 +110,14 @@ pub fn build_view( 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")); + let repo_vcs = repo.and_then(|repo| vcs::repo_vcs(repo)); let tint = module_index % 8; for artifact in &mut module.artifacts { artifact.module.clone_from(&module.name); artifact.module_tint = tint; + artifact.vcs = repo_vcs + .as_ref() + .map(|state| state.state_for(&artifact.relative_path)); let (broken, age) = artifact_staleness( repo, &artifact.relative_path, diff --git a/src/services/target.rs b/src/services/target.rs index 7bd362b..3675d22 100644 --- a/src/services/target.rs +++ b/src/services/target.rs @@ -188,6 +188,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..225c93f --- /dev/null +++ b/src/services/vcs.rs @@ -0,0 +1,223 @@ +//! 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 crate::view::{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, + } + } + + /// 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) = git_stdout( + module_dir, + &["rev-list", "--left-right", "--count", "@{upstream}...HEAD"], + ) + .and_then(|out| parse_counts(&out)) + .unwrap_or((0, 0)); + let status = git_stdout(module_dir, &["status", "--porcelain"]).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() +} + +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()) +} + +/// `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 porcelain v1 status lines into (dirty, untracked) path sets. +/// Renames keep the new path; quoted paths are unquoted. +fn parse_status(raw: &str) -> (HashSet, HashSet) { + let mut dirty = HashSet::new(); + let mut untracked = HashSet::new(); + for line in raw.lines() { + if line.len() < 4 { + continue; + } + let (code, rest) = line.split_at(3); + let path = rest + .rsplit(" -> ") + .next() + .unwrap_or(rest) + .trim_matches('"') + .to_string(); + if code.starts_with("??") { + untracked.insert(path); + } else { + dirty.insert(path); + } + } + (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\n?? skills/NewSkill/\nR old.md -> rules/new.md\n"); + assert!(dirty.contains("agents/Analyst.md")); + assert!(dirty.contains("rules/new.md")); + assert!(untracked.contains("skills/NewSkill/")); + assert!(!dirty.contains("old.md")); + } + + #[test] + fn untracked_directory_covers_children() { + let (dirty, untracked) = parse_status("?? skills/NewSkill/\n"); + 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..51d8803 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -21,7 +21,10 @@ use commands::{ self, builders, files::{self, FileSections}, }, - view::{Adr, ArtifactView, DashboardView, ModuleView, ProvenanceArtifact, StatusSummary}, + view::{ + Adr, ArtifactView, DashboardView, ModuleView, ProvenanceArtifact, StatusSummary, + WorktreeState, + }, }; use crate::cli::{config, watchlist}; @@ -352,6 +355,9 @@ struct PreviewCache { path: String, width: u16, lines: Vec>, + /// True when glow produced the lines: glow wraps at the pane width itself, + /// so the paragraph must not re-wrap (that breaks tables and prose). + glow: bool, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -510,6 +516,13 @@ impl App { 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; @@ -821,12 +834,13 @@ impl App { DetailTab::History => history_lines(artifact), DetailTab::Companions => companion_lines(artifact), }; - frame.render_widget( - Paragraph::new(Text::from(lines)) - .wrap(Wrap { trim: false }) - .scroll((self.detail_scroll, 0)), - chunks[1], - ); + let glow_wrapped = self.detail_tab == DetailTab::Preview + && self.preview_cache.as_ref().is_some_and(|cache| cache.glow); + let mut paragraph = Paragraph::new(Text::from(lines)).scroll((self.detail_scroll, 0)); + if !glow_wrapped { + paragraph = paragraph.wrap(Wrap { trim: false }); + } + frame.render_widget(paragraph, chunks[1]); } fn prepare_artifact_detail_cache( @@ -845,11 +859,12 @@ impl App { .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); + let (lines, glow) = preview_lines_for_width(artifact, cache_width); self.preview_cache = Some(PreviewCache { path, width: cache_width, lines, + glow, }); #[cfg(test)] { @@ -1771,7 +1786,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() { @@ -2175,13 +2190,20 @@ 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 + let row_width = rows .iter() - .map(|row| row.label.chars().count()) + .map(|row| { + let detail_width = if row.detail.is_empty() { + 0 + } else { + row.detail.chars().count().saturating_add(2) + }; + row.label.chars().count().saturating_add(detail_width) + }) .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(4)).clamp(MIDDLE_MIN_WIDTH, MIDDLE_MAX_WIDTH); MillerColumnWidths { left, middle } } @@ -2227,21 +2249,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(), @@ -2282,7 +2292,73 @@ fn hint_row() -> String { .join(" · ") } -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())]; + } + 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,14 +2379,17 @@ fn preview_lines_for_width(artifact: &ArtifactView, width: u16) -> Vec Vec Vec> { diff --git a/src/tui/glow_style.json b/src/tui/glow_style.json new file mode 100644 index 0000000..96100ef --- /dev/null +++ b/src/tui/glow_style.json @@ -0,0 +1,191 @@ +{ + "document": { + "block_prefix": "", + "block_suffix": "\n", + "color": "252", + "margin": 0 + }, + "block_quote": { + "indent": 1, + "indent_token": "\u2502 " + }, + "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": "## " + }, + "h3": { + "prefix": "### " + }, + "h4": { + "prefix": "#### " + }, + "h5": { + "prefix": "##### " + }, + "h6": { + "prefix": "###### ", + "color": "35", + "bold": false + }, + "text": {}, + "strikethrough": { + "crossed_out": true + }, + "emph": { + "italic": true + }, + "strong": { + "bold": true + }, + "hr": { + "color": "240", + "format": "\n--------\n" + }, + "item": { + "block_prefix": "\u2022 " + }, + "enumeration": { + "block_prefix": ". " + }, + "task": { + "ticked": "[\u2713] ", + "unticked": "[ ] " + }, + "link": { + "color": "30", + "underline": true + }, + "link_text": { + "color": "35", + "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": {}, + "definition_list": {}, + "definition_term": {}, + "definition_description": { + "block_prefix": "\n\ud83e\udc36 " + }, + "html_block": {}, + "html_span": {} +} \ No newline at end of file diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 2cdd9fd..4e42580 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -14,9 +14,9 @@ use crossterm::{ event as terminal_event, 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 +33,75 @@ 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), + "companions" => Some(DetailTab::Companions), + _ => None, + } +} + fn launch() -> Result<(), Box> { let mut app = App::load(PathBuf::from(".")); let mut terminal = setup_terminal()?; diff --git a/src/tui/rich.rs b/src/tui/rich.rs index 4cd1e17..f368925 100644 --- a/src/tui/rich.rs +++ b/src/tui/rich.rs @@ -21,8 +21,9 @@ pub fn render_markdown_with_glow(body: &str, width: u16) -> Option Option Option { + static STYLE_PATH: OnceLock> = OnceLock::new(); + STYLE_PATH + .get_or_init(|| { + let path = std::env::temp_dir().join("forge-glow-style.json"); + std::fs::write(&path, include_str!("glow_style.json")).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![ diff --git a/src/view.rs b/src/view.rs index 3bf4741..9e91735 100644 --- a/src/view.rs +++ b/src/view.rs @@ -246,6 +246,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 From 3d2b6586909f5a50c9e9b6d7b15c47f4a12bfbca Mon Sep 17 00:00:00 2001 From: Martin Zeman Date: Fri, 10 Jul 2026 02:06:22 +0200 Subject: [PATCH 02/22] feat: TUI comment reachable from any detail tab, context hints - m opens the tuicr comment prompt from Preview/Diff/etc by switching to Code - footer hint row is context-sensitive when the detail pane is focused - toasts clear on the next keypress instead of masking the hints forever --- src/tui/app.rs | 28 ++++++++++++++++++++++++---- src/tui/event.rs | 1 + src/tui/tests.rs | 14 ++++++++++++++ 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 51d8803..d71161e 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -85,7 +85,7 @@ pub const KEYBINDINGS: &[(&str, &[(&str, &str)])] = &[ ("d", "diff tab"), ("c", "code tab"), ("p", "preview tab"), - ("m", "comment current code line"), + ("m", "comment line (from any detail tab)"), ("Y", "copy tuicr comments"), ], ), @@ -659,7 +659,7 @@ impl App { } else if let Some(toast) = &self.toast { format!(" {toast}") } else { - hint_row() + hint_row(self.focused) }; frame.render_widget( Paragraph::new(text).style(Style::default().fg(Color::DarkGray)), @@ -1213,6 +1213,12 @@ impl App { } } + /// 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_section_by_shortcut(&mut self, character: char) -> bool { let Some(section) = Section::from_shortcut(character) else { return false; @@ -1467,7 +1473,10 @@ impl App { KeyCode::Char('6') => self.set_detail_tab(DetailTab::History), KeyCode::Char('7') => self.set_detail_tab(DetailTab::Companions), KeyCode::Tab => self.next_detail_tab(), - KeyCode::Char('m') if self.detail_tab == DetailTab::Code => { + KeyCode::Char('m') => { + if self.detail_tab != DetailTab::Code { + self.set_detail_tab(DetailTab::Code); + } self.open_comment_prompt(); } _ => {} @@ -2282,7 +2291,18 @@ fn value_or_any(value: &str) -> &str { if value.is_empty() { "any" } else { value } } -fn hint_row() -> String { +fn hint_row(focused: ColumnFocus) -> String { + if focused == ColumnFocus::Detail { + return [ + "1-7 tabs", + "j/k scroll", + "m comment line", + "Y copy review", + "h back", + "? help", + ] + .join(" · "); + } KEYBINDINGS .iter() .flat_map(|(_, bindings)| bindings.iter()) diff --git a/src/tui/event.rs b/src/tui/event.rs index cc9ceba..158d232 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -3,6 +3,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use super::app::App; pub fn handle_key(app: &mut App, key: KeyEvent) { + app.clear_toast(); if app.is_preview_open() { handle_preview_key(app, key); return; diff --git a/src/tui/tests.rs b/src/tui/tests.rs index 643f388..f162a31 100644 --- a/src/tui/tests.rs +++ b/src/tui/tests.rs @@ -426,6 +426,20 @@ fn tuicr_digest_exports_line_comments() { assert!(digest.contains("**[ISSUE]** `skills/BuildSkill/SKILL.md:3`")); } +#[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 tuicr_comment_kind_cycles_in_order() { assert_eq!(CommentKind::Issue.next(), CommentKind::Note); From 65b92015351bdc4968ec82ed417823e8dd7ec295 Mon Sep 17 00:00:00 2001 From: Martin Zeman Date: Fri, 10 Jul 2026 02:16:47 +0200 Subject: [PATCH 03/22] fix: harden vcs scan per adversarial review - ArtifactView carries source_path; vcs matching prefers it over the deploy key so monorepo layouts resolve the right file - porcelain -z parsing: verbatim paths, unambiguous renames - ahead/behind falls back to the resolved branch upstream and origin/ when HEAD is detached (jj colocated) - glow style file lands via write-then-rename, safe across processes --- src/cli/dashboard/routes/artifact.rs | 2 + src/services/adr.rs | 1 + src/services/discovery.rs | 1 + src/services/mod.rs | 9 ++-- src/services/target.rs | 1 + src/services/vcs.rs | 80 ++++++++++++++++++++-------- src/tui/rich.rs | 7 ++- src/view.rs | 4 ++ 8 files changed, 78 insertions(+), 27 deletions(-) diff --git a/src/cli/dashboard/routes/artifact.rs b/src/cli/dashboard/routes/artifact.rs index 5a76c03..b0d2a4e 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( diff --git a/src/services/adr.rs b/src/services/adr.rs index c2f64c8..1b437e9 100644 --- a/src/services/adr.rs +++ b/src/services/adr.rs @@ -148,6 +148,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, diff --git a/src/services/discovery.rs b/src/services/discovery.rs index c4c6762..49f24ae 100644 --- a/src/services/discovery.rs +++ b/src/services/discovery.rs @@ -230,6 +230,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, diff --git a/src/services/mod.rs b/src/services/mod.rs index 9b059cd..e26066e 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -115,9 +115,12 @@ pub fn build_view( for artifact in &mut module.artifacts { artifact.module.clone_from(&module.name); artifact.module_tint = tint; - artifact.vcs = repo_vcs - .as_ref() - .map(|state| state.state_for(&artifact.relative_path)); + let vcs_path = if artifact.source_path.is_empty() { + &artifact.relative_path + } else { + &artifact.source_path + }; + artifact.vcs = repo_vcs.as_ref().map(|state| state.state_for(vcs_path)); let (broken, age) = artifact_staleness( repo, &artifact.relative_path, diff --git a/src/services/target.rs b/src/services/target.rs index 3675d22..b239680 100644 --- a/src/services/target.rs +++ b/src/services/target.rs @@ -174,6 +174,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, diff --git a/src/services/vcs.rs b/src/services/vcs.rs index 225c93f..e136872 100644 --- a/src/services/vcs.rs +++ b/src/services/vcs.rs @@ -59,13 +59,8 @@ pub(super) fn repo_vcs(module_dir: &Path) -> Option { 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) = git_stdout( - module_dir, - &["rev-list", "--left-right", "--count", "@{upstream}...HEAD"], - ) - .and_then(|out| parse_counts(&out)) - .unwrap_or((0, 0)); - let status = git_stdout(module_dir, &["status", "--porcelain"]).unwrap_or_default(); + 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 @@ -149,6 +144,37 @@ fn command_stdout(dir: &Path, program: &str, args: &[&str]) -> Option { 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)> { @@ -158,26 +184,27 @@ fn parse_counts(raw: &str) -> Option<(usize, usize)> { Some((behind, ahead)) } -/// Splits porcelain v1 status lines into (dirty, untracked) path sets. -/// Renames keep the new path; quoted paths are unquoted. +/// 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(); - for line in raw.lines() { - if line.len() < 4 { + let mut fields = raw.split('\0'); + while let Some(entry) = fields.next() { + if entry.len() < 4 || !entry.is_char_boundary(3) { continue; } - let (code, rest) = line.split_at(3); - let path = rest - .rsplit(" -> ") - .next() - .unwrap_or(rest) - .trim_matches('"') - .to_string(); + let (code, path) = entry.split_at(3); if code.starts_with("??") { - untracked.insert(path); - } else { - dirty.insert(path); + 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) @@ -190,16 +217,23 @@ mod tests { #[test] fn parse_status_separates_dirty_and_untracked() { let (dirty, untracked) = - parse_status(" M agents/Analyst.md\n?? skills/NewSkill/\nR old.md -> rules/new.md\n"); + 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/\n"); + let (dirty, untracked) = parse_status("?? skills/NewSkill/\0"); let repo = RepoVcs { branch: "main".to_string(), ahead: 0, diff --git a/src/tui/rich.rs b/src/tui/rich.rs index f368925..9138c51 100644 --- a/src/tui/rich.rs +++ b/src/tui/rich.rs @@ -51,7 +51,12 @@ fn glow_style_path() -> Option { STYLE_PATH .get_or_init(|| { let path = std::env::temp_dir().join("forge-glow-style.json"); - std::fs::write(&path, include_str!("glow_style.json")).ok()?; + let staging = std::env::temp_dir() + .join(format!("forge-glow-style-{}.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() diff --git a/src/view.rs b/src/view.rs index 9e91735..0e20dc6 100644 --- a/src/view.rs +++ b/src/view.rs @@ -224,6 +224,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, From 10360e9e639dcfcfa943f882ab061a86b3145a47 Mon Sep 17 00:00:00 2001 From: Martin Zeman Date: Fri, 10 Jul 2026 09:03:34 +0200 Subject: [PATCH 04/22] feat: TUI mouse support, windowed code view, highlighted preview fences - click focuses panes, selects sections and list rows, switches detail tabs; wheel scrolls the viewport under the cursor and never moves selection - code and glow-preview tabs render only the visible line window, so large files scroll at full speed instead of re-cloning every line per frame - fenced code blocks in previews highlight through syntect (the Code tab engine); glow renders the prose around them --- src/tui/app.rs | 177 ++++++++++++++++++++++++++++++++++++++++++++--- src/tui/event.rs | 14 +++- src/tui/mod.rs | 30 +++++--- src/tui/rich.rs | 143 +++++++++++++++++++++++++++++++++++++- src/tui/tests.rs | 54 ++++++++++++++- 5 files changed, 397 insertions(+), 21 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index d71161e..583ef1a 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -10,7 +10,7 @@ use std::{ use crossterm::event::{KeyCode, KeyEvent}; 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}, @@ -350,6 +350,16 @@ pub struct MillerColumnWidths { pub middle: u16, } +/// 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, +} + #[derive(Debug, Clone, PartialEq, Eq)] struct PreviewCache { path: String, @@ -442,6 +452,7 @@ pub struct App { preview: Option, help_state: HelpState, palette: Palette, + mouse_regions: MouseRegions, } impl App { @@ -509,6 +520,7 @@ impl App { preview: None, help_state: HelpState::Closed, palette: Palette::new(), + mouse_regions: MouseRegions::default(), } } @@ -614,6 +626,10 @@ 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.render_sections(frame, columns[0]); self.render_list(frame, columns[1]); self.render_detail(frame, columns[2]); @@ -821,28 +837,70 @@ impl App { .direction(Direction::Vertical) .constraints([Constraint::Length(2), Constraint::Min(1)]) .split(area); + self.mouse_regions.tabs = chunks[0]; self.render_tabs(frame, chunks[0]); self.prepare_artifact_detail_cache(module_index, artifact_index, chunks[1].width); + let viewport = usize::from(chunks[1].height.max(1)); + // Pre-wrapped tabs render only the visible window of their cached + // lines: cloning and laying out a whole file per frame makes large + // code views unusably slow. + let windowed = match self.detail_tab { + DetailTab::Code => true, + DetailTab::Preview => self.preview_cache.as_ref().is_some_and(|cache| cache.glow), + _ => false, + }; + if windowed { + self.clamp_detail_scroll(viewport); + } let module = &self.view.modules[module_index]; let artifact = &module.artifacts[artifact_index]; let lines = match self.detail_tab { + DetailTab::Preview if windowed => self.preview_window(viewport), DetailTab::Preview => self.preview_cache_lines(), - DetailTab::Code => self.code_cache_lines(artifact), + DetailTab::Code => self.code_window(artifact, viewport), 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), }; - let glow_wrapped = self.detail_tab == DetailTab::Preview - && self.preview_cache.as_ref().is_some_and(|cache| cache.glow); - let mut paragraph = Paragraph::new(Text::from(lines)).scroll((self.detail_scroll, 0)); - if !glow_wrapped { - paragraph = paragraph.wrap(Wrap { trim: false }); + let mut paragraph = Paragraph::new(Text::from(lines)); + if !windowed { + paragraph = paragraph + .wrap(Wrap { trim: false }) + .scroll((self.detail_scroll, 0)); } frame.render_widget(paragraph, chunks[1]); } + fn clamp_detail_scroll(&mut self, viewport: usize) { + let total = match self.detail_tab { + DetailTab::Code => self + .code_cache + .as_ref() + .map_or(0, |cache| cache.lines.len()), + _ => 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); + } + + 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( &mut self, module_index: usize, @@ -902,16 +960,19 @@ 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 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)); if let Some(marker) = line.spans.first_mut() { @@ -1199,6 +1260,67 @@ impl App { self.focus_previous(); } + /// 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 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; + let row = usize::from(y.saturating_sub(regions.sections.y.saturating_add(1))); + if y > regions.sections.y && row < Section::ALL.len() { + self.set_section(Section::ALL[row]); + } + } else if regions.list.contains(position) { + self.focused = ColumnFocus::List; + let row = usize::from(y.saturating_sub(regions.list.y.saturating_add(1))); + self.ensure_rows(); + let rows = self.cached_rows(); + if y > regions.list.y && rows.get(row).is_some_and(ListRow::is_selectable) { + self.list_selected[self.section as usize] = row; + } + } else if regions.detail.contains(position) { + self.focused = ColumnFocus::Detail; + } + } + + /// 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; + } + if self.mouse_regions.detail.contains(Position { x, y }) { + self.detail_scroll = if down { + self.detail_scroll.saturating_add(WHEEL_STEP) + } else { + self.detail_scroll.saturating_sub(WHEEL_STEP) + }; + } + } + pub fn focused_key(&mut self, key: KeyEvent) { match self.focused { ColumnFocus::Sections => self.section_key(key), @@ -1238,6 +1360,24 @@ impl App { self.detail_tab } + #[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 { @@ -2291,6 +2431,23 @@ fn value_or_any(value: &str) -> &str { if value.is_empty() { "any" } else { value } } +/// Maps a column inside the tab bar to its tab, mirroring the span layout in +/// `render_tabs`: one leading space, then `"{number} {label}"` per tab. +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 start = cursor.saturating_add(1); + let end = start.saturating_add(width); + if column >= start && column < end { + return Some(*tab); + } + cursor = end; + } + None +} + fn hint_row(focused: ColumnFocus) -> String { if focused == ColumnFocus::Detail { return [ diff --git a/src/tui/event.rs b/src/tui/event.rs index 158d232..063a336 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -1,7 +1,19 @@ -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) { + 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() { diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 4e42580..ff32a8c 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -11,7 +11,9 @@ 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, backend::TestBackend}; @@ -115,20 +117,30 @@ fn launch() -> Result<(), Box> { fn setup_terminal() -> io::Result { enable_raw_mode()?; let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen, Hide)?; + execute!(stdout, EnterAlternateScreen, EnableMouseCapture, Hide)?; let backend = CrosstermBackend::new(stdout); Terminal::new(backend) } 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() { @@ -143,10 +155,12 @@ 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), + _ => {} + } } } Ok(()) diff --git a/src/tui/rich.rs b/src/tui/rich.rs index 9138c51..16475b5 100644 --- a/src/tui/rich.rs +++ b/src/tui/rich.rs @@ -16,11 +16,36 @@ 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 mut lines: Vec> = Vec::new(); + for segment in split_fences(body) { + match segment { + Segment::Prose(text) => { + if text.trim().is_empty() { + continue; + } + lines.extend(glow_lines(&text, 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", &style, "-w", &width.to_string()]) @@ -31,7 +56,7 @@ pub fn render_markdown_with_glow(body: &str, width: u16) -> Option Option Vec { + let mut segments = Vec::new(); + let mut current = String::new(); + let mut fence_language: Option = None; + for line in body.lines() { + let trimmed = line.trim_start(); + if trimmed.starts_with("```") { + if let Some(language) = fence_language.take() { + segments.push(Segment::Code { + language, + source: std::mem::take(&mut current), + }); + } else { + if !current.is_empty() { + segments.push(Segment::Prose(std::mem::take(&mut current))); + } + fence_language = Some(trimmed.trim_start_matches('`').trim().to_string()); + } + continue; + } + current.push_str(line); + current.push('\n'); + } + if !current.is_empty() { + segments.push(match fence_language { + 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. @@ -214,4 +320,39 @@ mod tests { let text = lines.iter().map(line_text).collect::>().join("\n"); assert!(text.contains("Title")); } + + #[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 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 f162a31..a9b39e5 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( @@ -426,6 +435,49 @@ 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, "2 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, "3 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, "1 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 comment_prompt_opens_from_preview_tab() { let mut app = fixture_app(); From 623356fdea02af44533aa6a3cca294202314168d Mon Sep 17 00:00:00 2001 From: Martin Zeman Date: Fri, 10 Jul 2026 09:05:09 +0200 Subject: [PATCH 05/22] fix: reference links resolve across preview fence segments Prose segments hand glow the full reference-definition list, so links keep resolving when their definition lives past a code fence. --- src/tui/rich.rs | 42 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/src/tui/rich.rs b/src/tui/rich.rs index 16475b5..63e8bf9 100644 --- a/src/tui/rich.rs +++ b/src/tui/rich.rs @@ -24,14 +24,28 @@ pub fn render_markdown_with_glow(body: &str, width: u16) -> Option 1 { + reference_definitions(body) + } else { + String::new() + }; let mut lines: Vec> = Vec::new(); - for segment in split_fences(body) { + for segment in segments { match segment { Segment::Prose(text) => { if text.trim().is_empty() { continue; } - lines.extend(glow_lines(&text, width)?); + 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) { @@ -73,6 +87,17 @@ enum Segment { Code { language: String, source: String }, } +/// Collects `[label]: target` reference-link definition lines from the body. +fn reference_definitions(body: &str) -> String { + body.lines() + .filter(|line| { + let trimmed = line.trim_start(); + trimmed.starts_with('[') && trimmed.contains("]:") + }) + .collect::>() + .join("\n") +} + /// Splits a markdown body at triple-backtick fences. The fence lines are /// dropped; the opening fence's info string becomes the code language. /// An unclosed fence keeps the rest of the body as code. @@ -321,6 +346,19 @@ mod tests { 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"); From 1dc87e4fced348a9e608008a61a70cfd7e463408 Mon Sep 17 00:00:00 2001 From: Martin Zeman Date: Fri, 10 Jul 2026 09:15:12 +0200 Subject: [PATCH 06/22] fix: harden mouse hit-testing, code cursor reach, fence parsing - code-tab scroll clamps to the last line, not the last page, so every visible line can become current and take a comment - the code window wraps its visible slice: long lines stay readable - border and blank-row clicks focus a pane but never select - fences respect CommonMark: four-column indents are literals, and a block closes only on a fence at least as long as its opener --- src/tui/app.rs | 59 ++++++++++++++++++++++++++++++++-------------- src/tui/rich.rs | 62 ++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 91 insertions(+), 30 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 583ef1a..845bcbb 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -865,27 +865,40 @@ impl App { DetailTab::Companions => companion_lines(artifact), }; let mut paragraph = Paragraph::new(Text::from(lines)); - if !windowed { - paragraph = paragraph - .wrap(Wrap { trim: false }) - .scroll((self.detail_scroll, 0)); + match (windowed, self.detail_tab) { + // The code window is pre-sliced; wrapping the visible slice keeps + // long lines readable without paying whole-file layout costs. + (true, DetailTab::Code) => paragraph = paragraph.wrap(Wrap { trim: false }), + // Glow output is pre-wrapped at pane width; re-wrapping breaks + // tables. + (true, _) => {} + (false, _) => { + paragraph = paragraph + .wrap(Wrap { trim: false }) + .scroll((self.detail_scroll, 0)); + } } frame.render_widget(paragraph, chunks[1]); } fn clamp_detail_scroll(&mut self, viewport: usize) { - let total = match self.detail_tab { + // The Code tab's 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 = match self.detail_tab { DetailTab::Code => self .code_cache .as_ref() - .map_or(0, |cache| cache.lines.len()), + .map_or(0, |cache| cache.lines.len()) + .saturating_sub(1), _ => self .preview_cache .as_ref() - .map_or(0, |cache| cache.lines.len()), + .map_or(0, |cache| cache.lines.len()) + .saturating_sub(viewport), }; - let max_scroll = u16::try_from(total.saturating_sub(viewport)).unwrap_or(u16::MAX); - self.detail_scroll = self.detail_scroll.min(max_scroll); + self.detail_scroll = self + .detail_scroll + .min(u16::try_from(max_scroll).unwrap_or(u16::MAX)); } fn preview_window(&self, viewport: usize) -> Vec> { @@ -1275,22 +1288,26 @@ impl App { let regions = self.mouse_regions; if regions.tabs.contains(position) { self.focused = ColumnFocus::Detail; - if let Some(tab) = tab_at_column(x.saturating_sub(regions.tabs.x)) { + 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; - let row = usize::from(y.saturating_sub(regions.sections.y.saturating_add(1))); - if y > regions.sections.y && row < Section::ALL.len() { + 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; - let row = usize::from(y.saturating_sub(regions.list.y.saturating_add(1))); - self.ensure_rows(); - let rows = self.cached_rows(); - if y > regions.list.y && rows.get(row).is_some_and(ListRow::is_selectable) { - self.list_selected[self.section as usize] = row; + if let Some(row) = bordered_row_at(regions.list, x, y) { + self.ensure_rows(); + let rows = self.cached_rows(); + if rows.get(row).is_some_and(ListRow::is_selectable) { + self.list_selected[self.section as usize] = row; + } } } else if regions.detail.contains(position) { self.focused = ColumnFocus::Detail; @@ -2431,6 +2448,14 @@ fn value_or_any(value: &str) -> &str { if value.is_empty() { "any" } else { value } } +/// 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 leading space, then `"{number} {label}"` per tab. fn tab_at_column(column: u16) -> Option { diff --git a/src/tui/rich.rs b/src/tui/rich.rs index 63e8bf9..bc339de 100644 --- a/src/tui/rich.rs +++ b/src/tui/rich.rs @@ -98,35 +98,49 @@ fn reference_definitions(body: &str) -> String { .join("\n") } -/// Splits a markdown body at triple-backtick fences. The fence lines are -/// dropped; the opening fence's info string becomes the code language. -/// An unclosed fence keeps the rest of the body as code. +/// 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 fence_language: Option = None; + let mut open_fence: Option<(String, usize)> = None; for line in body.lines() { let trimmed = line.trim_start(); - if trimmed.starts_with("```") { - if let Some(language) = fence_language.take() { + 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), }); - } else { + } + (None, true) => { if !current.is_empty() { segments.push(Segment::Prose(std::mem::take(&mut current))); } - fence_language = Some(trimmed.trim_start_matches('`').trim().to_string()); + open_fence = Some((trimmed[backticks..].trim().to_string(), backticks)); + } + _ => { + current.push_str(line); + current.push('\n'); } - continue; } - current.push_str(line); - current.push('\n'); } if !current.is_empty() { - segments.push(match fence_language { - Some(language) => Segment::Code { + segments.push(match open_fence { + Some((language, _)) => Segment::Code { language, source: current, }, @@ -374,6 +388,28 @@ mod tests { 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() { From c3ee901ad64fced6255a06eac986322b11c8e146 Mon Sep 17 00:00:00 2001 From: Martin Zeman Date: Fri, 10 Jul 2026 10:53:38 +0200 Subject: [PATCH 07/22] feat: rich zoom, full ADR body, real diff, repo detail, cached tabs - the fullscreen zoom renders through the same pipeline as the detail pane: glow preview, highlighted code, colored diff; digits switch tabs while open - ADR detail shows the whole document through the markdown renderer instead of a truncated summary paragraph - the Diff tab shows the source file's uncommitted changes, pager-colored; Provenance appends the adoption sidecar highlighted as YAML - repository detail gains local path, branch/jj state, recent commits, and o/O to open gitui or jjui in the repo (TUI suspends and resumes) - every detail tab renders from a cache keyed by target, tab, and width; per-frame rebuilds were the main source of input lag --- src/services/adr.rs | 1 + src/services/builders.rs | 3 + src/services/discovery.rs | 3 + src/services/mod.rs | 7 + src/services/source.rs | 2 +- src/services/target.rs | 3 + src/services/vcs.rs | 32 +- src/tui/app.rs | 534 ++++++++++++++++++++++++---------- src/tui/components/preview.rs | 145 +++++---- src/tui/event.rs | 9 + src/tui/mod.rs | 25 ++ src/tui/tests.rs | 7 + src/view.rs | 8 + 13 files changed, 572 insertions(+), 207 deletions(-) diff --git a/src/services/adr.rs b/src/services/adr.rs index 1b437e9..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(), }); } } 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 49f24ae..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(), }) } diff --git a/src/services/mod.rs b/src/services/mod.rs index e26066e..c9c4437 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -23,6 +23,7 @@ pub use history::{ extract_frontmatter_field, git_log_for_artifact, read_source_adoption, read_source_sidecar, source_at_deploy, }; +pub use source::strip_frontmatter; use crate::error::{Error, ErrorKind}; use crate::provider::ContentKind; @@ -98,6 +99,9 @@ pub fn build_view( source_uri: String::new(), is_target: false, artifacts: Vec::new(), + local_path: None, + vcs: None, + git_log: Vec::new(), }); } @@ -111,6 +115,9 @@ pub fn build_view( module.artifacts.sort_by(|a, b| a.name.cmp(&b.name)); let repo = local_repos.get(module.source_uri.trim_end_matches(".git")); let repo_vcs = repo.and_then(|repo| vcs::repo_vcs(repo)); + module.local_path = repo.cloned(); + module.vcs = repo_vcs.as_ref().map(vcs::RepoVcs::module_state); + module.git_log = repo.map(|repo| vcs::repo_log(repo)).unwrap_or_default(); let tint = module_index % 8; for artifact in &mut module.artifacts { artifact.module.clone_from(&module.name); diff --git a/src/services/source.rs b/src/services/source.rs index 245f99f..105b873 100644 --- a/src/services/source.rs +++ b/src/services/source.rs @@ -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 b239680..7ad4420 100644 --- a/src/services/target.rs +++ b/src/services/target.rs @@ -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 { diff --git a/src/services/vcs.rs b/src/services/vcs.rs index e136872..5cf1753 100644 --- a/src/services/vcs.rs +++ b/src/services/vcs.rs @@ -6,7 +6,8 @@ use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::process::Command; -use crate::view::{VcsState, WorktreeState}; +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, @@ -43,6 +44,25 @@ impl RepoVcs { } } + /// 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 { @@ -128,6 +148,16 @@ fn branch_label(dir: &Path, jj_colocated: bool) -> String { .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) } diff --git a/src/tui/app.rs b/src/tui/app.rs index 845bcbb..a6af56c 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -22,7 +22,7 @@ use commands::{ files::{self, FileSections}, }, view::{ - Adr, ArtifactView, DashboardView, ModuleView, ProvenanceArtifact, StatusSummary, + Adr, ArtifactView, DashboardView, ModuleView, ProvenanceArtifact, StatusSummary, VcsState, WorktreeState, }, }; @@ -87,6 +87,7 @@ pub const KEYBINDINGS: &[(&str, &[(&str, &str)])] = &[ ("p", "preview tab"), ("m", "comment line (from any detail tab)"), ("Y", "copy tuicr comments"), + ("o/O", "open gitui / jjui on repository"), ], ), ("Global", &[("?", "help"), ("F1", "help"), ("q", "quit")]), @@ -206,7 +207,7 @@ pub enum DetailTab { } impl DetailTab { - const ALL: [Self; DETAIL_TAB_COUNT] = [ + pub(super) const ALL: [Self; DETAIL_TAB_COUNT] = [ Self::Preview, Self::Code, Self::Diff, @@ -216,7 +217,7 @@ impl DetailTab { Self::Companions, ]; - fn label(self) -> &'static str { + pub(super) fn label(self) -> &'static str { match self { Self::Preview => "Preview", Self::Code => "Code", @@ -360,14 +361,17 @@ struct MouseRegions { tabs: 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>, - /// True when glow produced the lines: glow wraps at the pane width itself, - /// so the paragraph must not re-wrap (that breaks tables and prose). - glow: bool, + /// Lines already wrapped at the pane width (glow output): render a + /// scrolled window without Paragraph wrap, which would break tables. + windowed: bool, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -433,7 +437,7 @@ 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>, comment_prompt: Option, @@ -453,6 +457,9 @@ pub struct App { help_state: HelpState, palette: Palette, mouse_regions: MouseRegions, + /// External TUI (gitui/jjui) queued to run in a repo; the event loop + /// suspends the terminal, runs it, and resumes. + pending_external: Option<(String, PathBuf)>, } impl App { @@ -521,6 +528,7 @@ impl App { help_state: HelpState::Closed, palette: Palette::new(), mouse_regions: MouseRegions::default(), + pending_external: None, } } @@ -601,8 +609,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; } @@ -773,15 +808,15 @@ 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() { + self.render_adr_rich(frame, inner, &adr); } else { frame.render_widget(Paragraph::new("ADR not found"), inner); } } Some(ListTarget::Module(name)) => { if let Some(module) = self.view.modules.iter().find(|module| module.name == name) { - render_module_detail(frame, inner, module); + render_module_detail(frame, inner, module, self.detail_scroll); } else { frame.render_widget(Paragraph::new("repository not found"), inner); } @@ -840,65 +875,97 @@ impl App { self.mouse_regions.tabs = chunks[0]; self.render_tabs(frame, chunks[0]); self.prepare_artifact_detail_cache(module_index, artifact_index, chunks[1].width); - let viewport = usize::from(chunks[1].height.max(1)); - // Pre-wrapped tabs render only the visible window of their cached - // lines: cloning and laying out a whole file per frame makes large - // code views unusably slow. - let windowed = match self.detail_tab { - DetailTab::Code => true, - DetailTab::Preview => self.preview_cache.as_ref().is_some_and(|cache| cache.glow), - _ => false, - }; - if windowed { - self.clamp_detail_scroll(viewport); - } - let module = &self.view.modules[module_index]; - let artifact = &module.artifacts[artifact_index]; - let lines = match self.detail_tab { - DetailTab::Preview if windowed => self.preview_window(viewport), - DetailTab::Preview => self.preview_cache_lines(), - DetailTab::Code => self.code_window(artifact, viewport), - 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), - }; - let mut paragraph = Paragraph::new(Text::from(lines)); - match (windowed, self.detail_tab) { - // The code window is pre-sliced; wrapping the visible slice keeps - // long lines readable without paying whole-file layout costs. - (true, DetailTab::Code) => paragraph = paragraph.wrap(Wrap { trim: false }), - // Glow output is pre-wrapped at pane width; re-wrapping breaks - // tables. - (true, _) => {} - (false, _) => { - paragraph = paragraph - .wrap(Wrap { trim: false }) - .scroll((self.detail_scroll, 0)); + if self.detail_tab == DetailTab::Code { + let viewport = usize::from(chunks[1].height.max(1)); + // 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); + // The window is pre-sliced; wrapping the visible slice keeps long + // lines readable without paying whole-file layout costs. + frame.render_widget( + Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }), + 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. + fn render_adr_rich(&mut self, frame: &mut Frame<'_>, area: Rect, adr: &Adr) { + let cache_width = area.width.max(1); + let key = format!("adr:{}", adr.local_path); + let needs_build = self + .preview_cache + .as_ref() + .is_none_or(|cache| cache.key != key || cache.width != cache_width); + if needs_build { + let raw = std::fs::read_to_string(&adr.local_path) + .unwrap_or_else(|error| format!("could not read {}: {error}", adr.local_path)); + let body = commands::services::strip_frontmatter(&raw); + let mut lines = vec![ + Line::from(Span::styled( + format!("{} {}", adr.id, adr.title), + Style::default().add_modifier(Modifier::BOLD), + )), + Line::from(format!( + "{} · {} · {} · {}", + adr.repo, adr.state, adr.status, adr.relative_path + )), + Line::from(""), + ]; + let rendered = rich::render_markdown_with_glow(&body, cache_width); + let windowed = rendered.is_some(); + match rendered { + Some(rendered) => lines.extend(rendered), + None => lines.extend(body.lines().map(|line| Line::from(line.to_string()))), } + self.preview_cache = Some(DetailCache { + key, + width: cache_width, + lines, + windowed, + }); } - frame.render_widget(paragraph, chunks[1]); + self.render_cached_detail(frame, area); } - fn clamp_detail_scroll(&mut self, viewport: usize) { - // The Code tab's 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 = match self.detail_tab { - DetailTab::Code => self - .code_cache - .as_ref() - .map_or(0, |cache| cache.lines.len()) - .saturating_sub(1), - _ => self + /// 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)); + 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()) - .saturating_sub(viewport), - }; - self.detail_scroll = self - .detail_scroll - .min(u16::try_from(max_scroll).unwrap_or(u16::MAX)); + .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 lines = self.preview_window(viewport); + frame.render_widget(Paragraph::new(Text::from(lines)), area); + } else { + 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> { @@ -920,50 +987,77 @@ 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, glow) = preview_lines_for_width(artifact, cache_width); - self.preview_cache = Some(PreviewCache { - path, - width: cache_width, - lines, - glow, - }); - #[cfg(test)] - { - self.preview_cache_build_count += 1; - } + let cache_width = width.max(1); + if self.detail_tab == 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; } } - 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 artifact = &self.view.modules[module_index].artifacts[artifact_index]; + detail_cache_key(self.detail_tab, &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 { + 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) + }; + self.preview_cache = Some(DetailCache { + key, + width: cache_width, + lines, + windowed, + }); + #[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 => ( + rich::highlight_code(&artifact.relative_path, &artifact.raw_source), + true, + ), + DetailTab::Diff => (diff_lines(module, artifact), false), + DetailTab::Provenance => ( + module.map_or_else( + || vec![Line::from("module not found")], + |module| self.provenance_lines(module, artifact), + ), + false, + ), + DetailTab::Frontmatter => (frontmatter_lines(artifact), false), + DetailTab::History => (history_lines(artifact), false), + DetailTab::Companions => (companion_lines(artifact), false), } } @@ -1358,6 +1452,32 @@ impl App { 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 Some(ListTarget::Module(name)) = self.selected_target() else { + self.toast = Some(format!("{program}: select a repository first (section 5)")); + 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((program.to_string(), path)); + } + + pub fn take_external(&mut self) -> Option<(String, PathBuf)> { + self.pending_external.take() + } + pub fn set_section_by_shortcut(&mut self, character: char) -> bool { let Some(section) = Section::from_shortcut(character) else { return false; @@ -2175,12 +2295,42 @@ impl App { if !artifact.sidecar_warning.is_empty() { lines.push(Line::from(format!("sidecar: {}", artifact.sidecar_warning))); } + 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 render_module_detail(frame: &mut Frame<'_>, area: Rect, module: &ModuleView, scroll: u16) { + let mut lines = vec![ Line::from(Span::styled( module.name.clone(), Style::default().add_modifier(Modifier::BOLD), @@ -2191,34 +2341,79 @@ fn render_module_detail(frame: &mut Frame<'_>, area: Rect, module: &ModuleView) "role: {}", if module.is_target { "target" } else { "source" } )), - Line::from(""), - Line::from(module.description.clone()), - Line::from(""), - Line::from(format!("artifacts: {}", module.artifacts.len())), ]; + 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", + 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), + ))); + } frame.render_widget( - Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }), + Paragraph::new(Text::from(lines)) + .wrap(Wrap { trim: false }) + .scroll((scroll, 0)), area, ); } -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) { @@ -2607,28 +2802,65 @@ fn preview_lines_for_width(artifact: &ArtifactView, width: u16) -> (Vec Vec> { - let mut lines = vec![ - Line::from(Span::styled( - "Diff", - 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}"))), - ); +fn detail_cache_key(tab: DetailTab, path: &str) -> String { + format!("{tab:?}:{path}") +} + +/// Uncommitted changes to the artifact's source file, colored like a pager. +fn diff_lines(module: Option<&ModuleView>, artifact: &ArtifactView) -> 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() + }; + 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")]; + }; + 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 mut lines = vec![header, Line::default()]; + lines.extend(diff.lines().map(diff_line_colored)); lines } +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) -> Vec> { if artifact.metadata.is_empty() { return vec![Line::from("no frontmatter metadata")]; diff --git a/src/tui/components/preview.rs b/src/tui/components/preview.rs index 97c500a..58fe65e 100644 --- a/src/tui/components/preview.rs +++ b/src/tui/components/preview.rs @@ -2,59 +2,74 @@ 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 { - if width == 0 { - return u16::try_from(body.lines().count()).unwrap_or(u16::MAX); - } - 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) +use super::super::app::DetailTab; + +/// 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 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 +86,60 @@ 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-7 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; + }; + let total = pane.lines.len(); + 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 063a336..81b5e41 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -69,6 +69,8 @@ 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('o') => app.open_repo_tool(false), + KeyCode::Char('O') => app.open_repo_tool(true), _ => app.focused_key(key), } } @@ -82,6 +84,13 @@ 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'..='7') => { + 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), _ => {} } } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index ff32a8c..f73ba77 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -162,6 +162,31 @@ fn event_loop(terminal: &mut TuiTerminal, app: &mut App) -> Result<(), Box {} } } + if let Some((program, directory)) = app.take_external() { + run_external_tool(terminal, app, &program, &directory)?; + } + } + 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, + program: &str, + directory: &std::path::Path, +) -> Result<(), Box> { + restore_terminal(terminal); + let status = std::process::Command::new(program) + .current_dir(directory) + .status(); + *terminal = setup_terminal()?; + terminal.clear()?; + 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/tests.rs b/src/tui/tests.rs index a9b39e5..18f9f89 100644 --- a/src/tui/tests.rs +++ b/src/tui/tests.rs @@ -72,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(), @@ -80,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 { @@ -114,6 +120,7 @@ fn fixture_view() -> DashboardView { state: "authored".to_string(), source: String::new(), summary: "Context summary".to_string(), + local_path: String::new(), }], } } diff --git a/src/view.rs b/src/view.rs index 0e20dc6..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 { From 7e6cae6051de0a54b55fd7d2de021bc9deaf2759 Mon Sep 17 00:00:00 2001 From: Martin Zeman Date: Fri, 10 Jul 2026 10:55:07 +0200 Subject: [PATCH 08/22] fix: module-qualify detail and code cache keys Two modules can ship the same relative path; the cache key now includes the module name so switching between them rebuilds instead of showing stale content. --- src/tui/app.rs | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index a6af56c..602d487 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -989,15 +989,16 @@ impl App { ) { let cache_width = width.max(1); if self.detail_tab == DetailTab::Code { - let artifact = &self.view.modules[module_index].artifacts[artifact_index]; - let path = artifact.relative_path.clone(); + 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 != path); + .is_none_or(|cache| cache.path != key); if needs_build { - let lines = rich::highlight_code(&path, &artifact.raw_source); - self.code_cache = Some(CodeCache { path, lines }); + 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; @@ -1006,8 +1007,9 @@ impl App { return; } let key = { - let artifact = &self.view.modules[module_index].artifacts[artifact_index]; - detail_cache_key(self.detail_tab, &artifact.relative_path) + 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 @@ -2802,8 +2804,10 @@ fn preview_lines_for_width(artifact: &ArtifactView, width: u16) -> (Vec String { - format!("{tab:?}:{path}") +/// 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}") } /// Uncommitted changes to the artifact's source file, colored like a pager. From b841d9a398539877ee9830015796fe814915d07a Mon Sep 17 00:00:00 2001 From: Martin Zeman Date: Fri, 10 Jul 2026 11:04:23 +0200 Subject: [PATCH 09/22] fix: honest diff states, fresh zoom after rescan, smooth list browsing - git diff failures and untracked files no longer render as "matches HEAD"; the exit status is checked and untracked artifacts say so - a completed rescan rebinds the open zoom to the fresh artifact, or closes it when the artifact is gone - Preview and Diff rebuilds wait for the input queue to drain, so holding j/k browses at full speed instead of serializing a subprocess per row --- src/tui/app.rs | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/src/tui/app.rs b/src/tui/app.rs index 602d487..4fc810f 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -558,6 +558,7 @@ impl App { self.toast = Some("scan complete".to_string()); self.invalidate_rows(); self.invalidate_detail_caches(); + self.refresh_open_preview(); self.clamp_list_selection(); } Ok(Err(error)) => { @@ -1016,6 +1017,13 @@ impl App { .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; + } let (lines, windowed) = { let module = &self.view.modules[module_index]; let artifact = &module.artifacts[artifact_index]; @@ -1795,6 +1803,34 @@ 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) = self.preview.as_ref().map(|preview| { + let artifact = preview.artifact(); + ( + artifact.module.clone(), + artifact.kind.clone(), + artifact.name.clone(), + ) + }) 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| ArtifactPreview::from_artifact(&artifact)); + } + fn cached_rows(&self) -> &[ListRow] { &self.cached_rows } @@ -2810,6 +2846,12 @@ 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. fn diff_lines(module: Option<&ModuleView>, artifact: &ArtifactView) -> Vec> { let header = Line::from(Span::styled( @@ -2819,6 +2861,19 @@ fn diff_lines(module: Option<&ModuleView>, artifact: &ArtifactView) -> Vec, artifact: &ArtifactView) -> Vec Date: Sun, 12 Jul 2026 10:25:20 +0200 Subject: [PATCH 10/22] =?UTF-8?q?fix:=20harness-panel=20review=20round=20?= =?UTF-8?q?=E2=80=94=20list=20viewport,=20comment=20identity,=20honest=20c?= =?UTF-8?q?opy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the list column scrolls: the viewport follows selection changes and the wheel scrolls it independently; clicks account for the offset - glow stdin feeds from a thread, removing the pipe deadlock on large bodies - comments are keyed by module as well as path, and the tuicr digest names the module; y really copies the source path via pbcopy - a deferred rebuild renders a placeholder instead of another artifact - rescan keeps the selected row by identity, not index; finishing gitui/jjui triggers a rescan; refresh is debounced while a scan runs - zoom clamps scrolling against wrapped display rows and survives rescans with its position; terminal state unwinds if setup fails partway - per-repo VCS state is scanned once per repo, staleness and VCS agree on the source path, and the glow style file is per-user - dashboard route test fixtures compile under --all-features --- src/cli/dashboard/routes/artifact.rs | 5 + src/services/mod.rs | 23 ++-- src/tui/app.rs | 151 ++++++++++++++++++++++----- src/tui/components/preview.rs | 27 ++++- src/tui/mod.rs | 17 ++- src/tui/rich.rs | 22 ++-- src/tui/tests.rs | 1 + 7 files changed, 203 insertions(+), 43 deletions(-) diff --git a/src/cli/dashboard/routes/artifact.rs b/src/cli/dashboard/routes/artifact.rs index b0d2a4e..f382ce7 100644 --- a/src/cli/dashboard/routes/artifact.rs +++ b/src/cli/dashboard/routes/artifact.rs @@ -656,6 +656,8 @@ mod tests { module_tint: 0, companions: Vec::new(), variants: Vec::new(), + source_path: String::new(), + vcs: None, } } @@ -667,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/services/mod.rs b/src/services/mod.rs index c9c4437..0935c8d 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -111,26 +111,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")); - let repo_vcs = repo.and_then(|repo| vcs::repo_vcs(repo)); + 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.as_ref().map(vcs::RepoVcs::module_state); - module.git_log = repo.map(|repo| vcs::repo_log(repo)).unwrap_or_default(); + 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 + artifact.relative_path.clone() } else { - &artifact.source_path + artifact.source_path.clone() }; - artifact.vcs = repo_vcs.as_ref().map(|state| state.state_for(vcs_path)); + 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/tui/app.rs b/src/tui/app.rs index 4fc810f..e6a31d6 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -416,6 +416,7 @@ struct LineComment { #[derive(Debug, Clone, PartialEq, Eq)] struct CommentPrompt { + module: String, path: String, line_number: usize, kind: CommentKind, @@ -439,7 +440,7 @@ pub struct App { row_build_count: usize, 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, @@ -460,6 +461,10 @@ pub struct App { /// External TUI (gitui/jjui) queued to run in a repo; the event loop /// suspends the terminal, runs it, and resumes. pending_external: Option<(String, PathBuf)>, + /// 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, } impl App { @@ -529,10 +534,16 @@ impl App { palette: Palette::new(), mouse_regions: MouseRegions::default(), pending_external: None, + list_offset: 0, + list_last_selected: 0, } } pub fn refresh(&mut self) { + if self.scan_state == ScanState::Loading { + self.toast = Some("scan already running".to_string()); + return; + } self.start_scan(); } @@ -556,9 +567,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)) => { @@ -743,26 +756,44 @@ impl App { frame.render_widget(List::new(items), inner); } - fn render_list(&self, frame: &mut Frame<'_>, area: Rect) { + fn render_list(&mut self, frame: &mut Frame<'_>, area: Rect) { let title = format!(" {} ", 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( @@ -897,7 +928,26 @@ impl App { chunks[1], ); } else { - self.render_cached_detail(frame, chunks[1]); + 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]); + } } } @@ -1080,6 +1130,7 @@ impl App { 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 @@ -1091,7 +1142,9 @@ impl App { .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 { " " }, @@ -1406,7 +1459,8 @@ impl App { } } else if regions.list.contains(position) { self.focused = ColumnFocus::List; - if let Some(row) = bordered_row_at(regions.list, x, y) { + 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(); if rows.get(row).is_some_and(ListRow::is_selectable) { @@ -1433,12 +1487,20 @@ impl App { if self.help_state == HelpState::Open { return; } - if self.mouse_regions.detail.contains(Position { x, y }) { + 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)) + }; } } @@ -1470,9 +1532,15 @@ impl App { /// 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 Some(ListTarget::Module(name)) = self.selected_target() else { - self.toast = Some(format!("{program}: select a repository first (section 5)")); - return; + let name = match self.selected_target() { + Some(ListTarget::Module(name)) => name, + Some( + ListTarget::Artifact { module, .. } | ListTarget::ProvenanceArtifact { module, .. }, + ) => module, + _ => { + 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; @@ -1601,8 +1669,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() + }); } } @@ -1628,13 +1703,14 @@ impl App { #[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(), @@ -1650,13 +1726,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 ) }, @@ -1668,15 +1745,17 @@ impl App { let Some(artifact) = self.selected_artifact() else { return; }; + let module = artifact.module.clone(); let path = artifact.relative_path.clone(); let line_number = self.current_code_line(artifact); 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, @@ -1690,12 +1769,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, @@ -1778,6 +1858,7 @@ impl App { fn set_section(&mut self, section: Section) { self.section = section; self.detail_scroll = 0; + self.list_offset = 0; self.invalidate_rows(); self.clamp_list_selection(); } @@ -1806,12 +1887,15 @@ impl App { /// 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) = self.preview.as_ref().map(|preview| { + let Some((open, scroll)) = self.preview.as_ref().map(|preview| { let artifact = preview.artifact(); ( - artifact.module.clone(), - artifact.kind.clone(), - artifact.name.clone(), + ( + artifact.module.clone(), + artifact.kind.clone(), + artifact.name.clone(), + ), + preview.scroll(), ) }) else { return; @@ -1828,7 +1912,11 @@ impl App { .find(|artifact| artifact.kind == open.1 && artifact.name == open.2) }) .cloned(); - self.preview = fresh.map(|artifact| ArtifactPreview::from_artifact(&artifact)); + self.preview = fresh.map(|artifact| { + let mut preview = ArtifactPreview::from_artifact(&artifact); + preview.scroll_down(scroll); + preview + }); } fn cached_rows(&self) -> &[ListRow] { @@ -2103,6 +2191,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(); diff --git a/src/tui/components/preview.rs b/src/tui/components/preview.rs index 58fe65e..adc6078 100644 --- a/src/tui/components/preview.rs +++ b/src/tui/components/preview.rs @@ -10,6 +10,20 @@ use commands::view::ArtifactView; 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. +fn wrapped_rows(lines: &[Line<'_>], width: u16) -> usize { + if width == 0 { + return lines.len(); + } + let width = usize::from(width); + 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)] @@ -45,6 +59,11 @@ impl ArtifactPreview { &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 @@ -110,7 +129,13 @@ impl ArtifactPreview { frame.render_widget(Paragraph::new("building preview...").block(block), area); return; }; - let total = pane.lines.len(); + // 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; diff --git a/src/tui/mod.rs b/src/tui/mod.rs index f73ba77..8f8a110 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -117,9 +117,17 @@ fn launch() -> Result<(), Box> { fn setup_terminal() -> io::Result { enable_raw_mode()?; let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture, 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) { @@ -183,6 +191,9 @@ fn run_external_tool( .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. + app.refresh(); match status { Ok(status) if status.success() => {} Ok(status) => app.set_toast(format!("{program} exited with {status}")), diff --git a/src/tui/rich.rs b/src/tui/rich.rs index bc339de..feff0b4 100644 --- a/src/tui/rich.rs +++ b/src/tui/rich.rs @@ -69,11 +69,16 @@ fn glow_lines(text: &str, width: u16) -> Option>> { .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(text.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; } @@ -195,9 +200,12 @@ fn glow_style_path() -> Option { static STYLE_PATH: OnceLock> = OnceLock::new(); STYLE_PATH .get_or_init(|| { - let path = std::env::temp_dir().join("forge-glow-style.json"); - let staging = std::env::temp_dir() - .join(format!("forge-glow-style-{}.json.tmp", std::process::id())); + 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()?; diff --git a/src/tui/tests.rs b/src/tui/tests.rs index 18f9f89..e7e2f09 100644 --- a/src/tui/tests.rs +++ b/src/tui/tests.rs @@ -431,6 +431,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, From f9939e2780d317801e7f9dfd1c1f6765b6effc7f Mon Sep 17 00:00:00 2001 From: Martin Zeman Date: Sun, 12 Jul 2026 11:26:58 +0200 Subject: [PATCH 11/22] =?UTF-8?q?fix:=20Fable=20panel=20round=20=E2=80=94?= =?UTF-8?q?=20safe=20escape,=20honest=20search=20mode,=20live=20scroll=20c?= =?UTF-8?q?lamps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Esc walks focus back and quits only from Sections; quitting with unsaved comments asks for a second press and points at Y - finishing gitui/jjui force-restarts the scan, superseding one in flight, so the view reflects what the tool changed - non-windowed detail scroll clamps against wrapped rows and resets when the selection changes, so short content never renders as a blank pane - ADR detail defers rebuilds while keys are queued, like artifacts - the review digest falls back to a temp file when pbcopy is missing instead of vanishing into the alternate screen - search has an explicit input mode: / focuses and edits the query, Enter or Esc returns keys to navigation, and the header shows which mode is on - the sections column shows the letter shortcuts that actually work for Settings/Hooks/Config/Schemas; G jumps to the bottom of the detail pane --- src/tui/app.rs | 124 +++++++++++++++++++++++++++++++--- src/tui/components/preview.rs | 2 +- src/tui/event.rs | 7 +- src/tui/mod.rs | 5 +- src/tui/tests.rs | 10 +-- 5 files changed, 130 insertions(+), 18 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index e6a31d6..170363c 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -31,7 +31,7 @@ use crate::cli::{config, watchlist}; use super::components::{ palette::{Palette, PaletteCommand}, - preview::ArtifactPreview, + preview::{ArtifactPreview, wrapped_rows}, }; use super::rich; @@ -175,6 +175,26 @@ impl Section { } } + /// The key that reaches this section from the Sections column, shown as + /// the row prefix so every advertised shortcut actually works. + fn shortcut_label(self) -> &'static str { + match self { + Self::Overview => "1", + Self::Skills => "2", + Self::Agents => "3", + Self::Rules => "4", + Self::Repositories => "5", + Self::Adrs => "6", + Self::Provenance => "7", + Self::Variants => "8", + Self::Search => "9", + Self::Settings => "t", + Self::Hooks => "h", + Self::Config => "c", + Self::Schemas => "m", + } + } + fn from_shortcut(character: char) -> Option { match character { '1' => Some(Self::Overview), @@ -465,6 +485,11 @@ pub struct App { 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, + /// Whether keystrokes in the Search section edit the query (explicit + /// input mode) or navigate the result list. + search_typing: bool, } impl App { @@ -536,6 +561,8 @@ impl App { pending_external: None, list_offset: 0, list_last_selected: 0, + quit_armed: false, + search_typing: false, } } @@ -547,6 +574,12 @@ impl App { 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] @@ -741,7 +774,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 { @@ -961,6 +995,20 @@ impl App { .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 raw = std::fs::read_to_string(&adr.local_path) .unwrap_or_else(|error| format!("could not read {}: {error}", adr.local_path)); let body = commands::services::strip_frontmatter(&raw); @@ -1009,6 +1057,12 @@ impl App { let lines = self.preview_window(viewport); 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)) @@ -1048,6 +1102,7 @@ impl App { .as_ref() .is_none_or(|cache| cache.path != key); if needs_build { + self.detail_scroll = 0; let lines = rich::highlight_code(&artifact.relative_path, &artifact.raw_source); self.code_cache = Some(CodeCache { path: key, lines }); #[cfg(test)] @@ -1074,6 +1129,15 @@ impl App { 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; + } let (lines, windowed) = { let module = &self.view.modules[module_index]; let artifact = &module.artifacts[artifact_index]; @@ -1271,9 +1335,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 @@ -1612,13 +1697,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; @@ -1689,14 +1781,20 @@ 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}"), + } }); } @@ -1832,6 +1930,7 @@ impl App { self.detail_scroll = self.detail_scroll.saturating_sub(10); } KeyCode::Home | KeyCode::Char('g') => self.detail_scroll = 0, + KeyCode::End | KeyCode::Char('G') => self.detail_scroll = u16::MAX, 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), @@ -2095,8 +2194,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) diff --git a/src/tui/components/preview.rs b/src/tui/components/preview.rs index adc6078..b4fa775 100644 --- a/src/tui/components/preview.rs +++ b/src/tui/components/preview.rs @@ -13,7 +13,7 @@ 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. -fn wrapped_rows(lines: &[Line<'_>], width: u16) -> usize { +pub(in super::super) fn wrapped_rows(lines: &[Line<'_>], width: u16) -> usize { if width == 0 { return lines.len(); } diff --git a/src/tui/event.rs b/src/tui/event.rs index 81b5e41..831ce11 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -51,12 +51,17 @@ 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); + app.begin_search_input(); } KeyCode::Char('r') => app.refresh(), KeyCode::Char('Y') => app.copy_tuicr_review(), diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 8f8a110..9688e0c 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -192,8 +192,9 @@ fn run_external_tool( *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. - app.refresh(); + // 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}")), diff --git a/src/tui/tests.rs b/src/tui/tests.rs index e7e2f09..a07887b 100644 --- a/src/tui/tests.rs +++ b/src/tui/tests.rs @@ -347,17 +347,19 @@ 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] From 299444f0b6018b0e2f0ddf387c60a33b5ea63d73 Mon Sep 17 00:00:00 2001 From: Martin Zeman Date: Sun, 12 Jul 2026 12:28:14 +0200 Subject: [PATCH 12/22] =?UTF-8?q?feat:=20council=20round=20one=20=E2=80=94?= =?UTF-8?q?=20rekeyed=20input,=20gitui=20letters,=20tuicr=20wrap,=20diff?= =?UTF-8?q?=20hunks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - digits switch sections from any focus; detail tabs cycle with Tab and the letters p/c/d/v/f/i/n; the tab bar drops its dead numbers - status column uses gitui letters (M modified, ? new, ! stale, · clean) - row detail renders only on the selected row; the list column slims to label width and nothing truncates while browsing - the Overview mode row toggles on Enter and click, not just m - word wrap preserves the gutter: continuation rows align after line numbers and key columns with a ↪ marker (code pane, zoom, frontmatter); wrap_plain keeps explicit newlines - diff gets hunk separators, [ and ] navigation with hunk x/y in the footer, and untracked files render their whole body as added lines --- Cargo.toml | 3 +- src/tui/app.rs | 272 ++++++++++++++++++++++++---------- src/tui/components/preview.rs | 2 +- src/tui/event.rs | 21 ++- src/tui/mod.rs | 1 + src/tui/tests.rs | 9 +- src/tui/word_wrap.rs | 181 ++++++++++++++++++++++ 7 files changed, 403 insertions(+), 86 deletions(-) create mode 100644 src/tui/word_wrap.rs 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/tui/app.rs b/src/tui/app.rs index 170363c..3088ae9 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -34,6 +34,7 @@ use super::components::{ 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; @@ -42,6 +43,9 @@ 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)])] = &[ ( @@ -82,9 +86,8 @@ pub const KEYBINDINGS: &[(&str, &[(&str, &str)])] = &[ (":", "palette"), ("r", "refresh"), ("y", "copy install snippet or path"), - ("d", "diff tab"), - ("c", "code tab"), - ("p", "preview tab"), + ("Tab", "next detail tab"), + ("p c d v f i n", "detail tabs"), ("m", "comment line (from any detail tab)"), ("Y", "copy tuicr comments"), ("o/O", "open gitui / jjui on repository"), @@ -289,6 +292,8 @@ enum HelpState { enum ListTarget { None, Overview, + /// The Overview Nested/Matrix mode row: activating it toggles the mode. + OverviewMode, Artifact { module: String, kind: String, @@ -392,6 +397,8 @@ struct DetailCache { /// 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, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -756,6 +763,8 @@ 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(self.focused) }; @@ -842,12 +851,20 @@ 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)), - ])) + ]; + // Detail text only on the selected row: unselected rows + // stay calm and nothing truncates while browsing. + if index == selected && !row.detail.is_empty() { + spans.push(Span::styled( + format!(" {}", row.detail), + base.fg(Color::DarkGray), + )); + } + ListItem::new(Line::from(spans)) }) .collect() }; @@ -955,12 +972,11 @@ impl App { .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); - // The window is pre-sliced; wrapping the visible slice keeps long - // lines readable without paying whole-file layout costs. - frame.render_widget( - Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }), - chunks[1], - ); + // 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]; @@ -1034,6 +1050,7 @@ impl App { width: cache_width, lines, windowed, + hunks: Vec::new(), }); } self.render_cached_detail(frame, area); @@ -1143,11 +1160,13 @@ impl App { let artifact = &module.artifacts[artifact_index]; self.build_detail_lines(Some(module), artifact, self.detail_tab, cache_width) }; + let hunks = hunk_offsets(&lines); self.preview_cache = Some(DetailCache { key, width: cache_width, lines, windowed, + hunks, }); #[cfg(test)] { @@ -1168,10 +1187,17 @@ impl App { match tab { DetailTab::Preview => preview_lines_for_width(artifact, width), DetailTab::Code => ( - rich::highlight_code(&artifact.relative_path, &artifact.raw_source), + 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), 1, usize::from(width)), true, ), - DetailTab::Diff => (diff_lines(module, artifact), false), DetailTab::Provenance => ( module.map_or_else( || vec![Line::from("module not found")], @@ -1179,7 +1205,7 @@ impl App { ), false, ), - DetailTab::Frontmatter => (frontmatter_lines(artifact), false), + DetailTab::Frontmatter => (frontmatter_lines(artifact, width), true), DetailTab::History => (history_lines(artifact), false), DetailTab::Companions => (companion_lines(artifact), false), } @@ -1231,8 +1257,7 @@ impl App { fn render_tabs(&self, frame: &mut Frame<'_>, area: Rect) { let spans = DetailTab::ALL .iter() - .enumerate() - .flat_map(|(index, tab)| { + .flat_map(|tab| { let style = if *tab == self.detail_tab { Style::default() .fg(Color::Black) @@ -1241,10 +1266,7 @@ impl App { } else { Style::default().fg(Color::DarkGray) }; - [ - Span::raw(" "), - Span::styled(format!("{} {}", index + 1, tab.label()), style), - ] + [Span::raw(" "), Span::styled(tab.label(), style)] }) .collect::>(); frame.render_widget(Paragraph::new(Line::from(spans)), area); @@ -1492,11 +1514,55 @@ impl App { }; } + /// 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); + } + } + + /// (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 => { + if let Some(ListTarget::OverviewMode) = self.selected_target() { + self.toggle_overview_mode(); + return; + } if let Some(ListTarget::ProvenanceArtifact { .. }) = self.selected_target() { self.detail_tab = DetailTab::Provenance; } @@ -1548,8 +1614,15 @@ impl App { let row = visual_row.saturating_add(self.list_offset); self.ensure_rows(); let rows = self.cached_rows(); - if rows.get(row).is_some_and(ListRow::is_selectable) { + 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 { self.list_selected[self.section as usize] = row; + if toggles { + self.toggle_overview_mode(); + } } } } else if regions.detail.contains(position) { @@ -1905,11 +1978,7 @@ 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(); } _ => {} } @@ -1931,13 +2000,15 @@ impl App { } KeyCode::Home | KeyCode::Char('g') => self.detail_scroll = 0, KeyCode::End | KeyCode::Char('G') => self.detail_scroll = u16::MAX, - 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(']') 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::Char('n') => self.set_detail_tab(DetailTab::Companions), KeyCode::Tab => self.next_detail_tab(), KeyCode::Char('m') => { if self.detail_tab != DetailTab::Code { @@ -2064,12 +2135,12 @@ impl App { 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", ), ] @@ -2794,20 +2865,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); + // 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| { - let detail_width = if row.detail.is_empty() { - 0 - } else { - row.detail.chars().count().saturating_add(2) - }; - row.label.chars().count().saturating_add(detail_width) - }) + .map(|row| row.label.chars().count()) .max() .unwrap_or_default(); let middle = - usize_to_u16(row_width.saturating_add(4)).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 } } @@ -2865,20 +2932,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), } } @@ -2895,16 +2964,15 @@ fn bordered_row_at(region: Rect, x: u16, y: u16) -> Option { } /// Maps a column inside the tab bar to its tab, mirroring the span layout in -/// `render_tabs`: one leading space, then `"{number} {label}"` per tab. +/// `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 start = cursor.saturating_add(1); - let end = start.saturating_add(width); - if column >= start && column < end { - return Some(*tab); + for tab in DetailTab::ALL { + let width = u16::try_from(tab.label().chars().count()).unwrap_or(u16::MAX); + let end = cursor.saturating_add(1).saturating_add(width); + if column < end { + return Some(tab); } cursor = end; } @@ -2914,11 +2982,11 @@ fn tab_at_column(column: u16) -> Option { fn hint_row(focused: ColumnFocus) -> String { if focused == ColumnFocus::Detail { return [ - "1-7 tabs", + "Tab/p c d v f i n tabs", + "1-9 sections", "j/k scroll", - "m comment line", + "m comment", "Y copy review", - "h back", "? help", ] .join(" · "); @@ -2939,6 +3007,13 @@ 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() { @@ -3057,8 +3132,13 @@ 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. -fn diff_lines(module: Option<&ModuleView>, artifact: &ArtifactView) -> Vec> { +/// 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), @@ -3066,24 +3146,37 @@ fn diff_lines(module: Option<&ModuleView>, artifact: &ArtifactView) -> Vec lines.extend(body.lines().map(|line| { + Line::from(Span::styled( + format!("+{line}"), + Style::default().fg(Color::Green), + )) + })), + Err(error) => lines.push(Line::from(format!("could not read {path}: {error}"))), + } + return lines; } - let path = if artifact.source_path.is_empty() { - artifact.relative_path.as_str() - } else { - artifact.source_path.as_str() - }; let output = std::process::Command::new("git") .args(["diff", "HEAD", "--", path]) .current_dir(repo) @@ -3108,11 +3201,34 @@ fn diff_lines(module: Option<&ModuleView>, artifact: &ArtifactView) -> Vec]) -> 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() @@ -3132,11 +3248,11 @@ fn diff_line_colored(line: &str) -> Line<'static> { Line::from(Span::styled(line.to_string(), style)) } -fn frontmatter_lines(artifact: &ArtifactView) -> Vec> { +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)| { @@ -3145,7 +3261,9 @@ 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> { diff --git a/src/tui/components/preview.rs b/src/tui/components/preview.rs index b4fa775..1134f12 100644 --- a/src/tui/components/preview.rs +++ b/src/tui/components/preview.rs @@ -117,7 +117,7 @@ impl ArtifactPreview { let block = Block::default() .title(title) .title_bottom(Line::from(Span::styled( - " 1-7 tabs · j/k · ␣/b page · g/G ends · Esc close ", + " p/c/d/v/f/i/n tabs · j/k · ␣/b page · g/G ends · Esc close ", Style::default().fg(Color::DarkGray), ))) .borders(Borders::ALL) diff --git a/src/tui/event.rs b/src/tui/event.rs index 831ce11..342f934 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -44,6 +44,13 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { app.search_input_key(key); return; } + // Digits switch sections from any focus; letter shortcuts (t/h/c/m) + // stay scoped to the Sections column where they are advertised. + if let KeyCode::Char(character @ '0'..='9') = key.code + && app.set_section_by_shortcut(character) + { + return; + } if app.has_section_digit_shortcuts() && let KeyCode::Char(character) = key.code && app.set_section_by_shortcut(character) @@ -74,6 +81,10 @@ 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('n') => app.set_detail_tab(super::app::DetailTab::Companions), KeyCode::Char('o') => app.open_repo_tool(false), KeyCode::Char('O') => app.open_repo_tool(true), _ => app.focused_key(key), @@ -89,13 +100,17 @@ 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'..='7') => { - let index = usize::from(digit as u8 - b'1'); - app.set_detail_tab(super::app::DetailTab::ALL[index]); + KeyCode::Char(digit @ '0'..='9') => { + app.close_preview(); + app.set_section_by_shortcut(digit); } 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('n') => app.set_detail_tab(super::app::DetailTab::Companions), _ => {} } } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 9688e0c..866be83 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}, diff --git a/src/tui/tests.rs b/src/tui/tests.rs index a07887b..c94a531 100644 --- a/src/tui/tests.rs +++ b/src/tui/tests.rs @@ -363,15 +363,16 @@ fn search_input_mode_is_explicit() { } #[test] -fn detail_digit_shortcut_selects_tab_without_changing_section() { +fn digit_switches_section_from_any_focus_and_letter_switches_tab() { let mut app = fixture_app(); app.focus_next(); app.focus_next(); event::handle_key(&mut app, key(KeyCode::Char('2'))); + assert_eq!(app.section(), Section::Skills); + event::handle_key(&mut app, key(KeyCode::Char('c'))); assert_eq!(app.detail_tab(), DetailTab::Code); - assert_eq!(app.section(), Section::Overview); } #[test] @@ -464,7 +465,7 @@ fn mouse_click_on_tab_switches_detail_tab() { app.drill_or_expand(); app.drill_or_expand(); let output = rendered(&mut app); - let (x, y) = buffer_position(&output, "3 Diff"); + let (x, y) = buffer_position(&output, "Diff"); app.mouse_click(x, y); @@ -479,7 +480,7 @@ fn mouse_wheel_scrolls_detail_without_moving_selection() { app.drill_or_expand(); app.drill_or_expand(); let output = rendered(&mut app); - let (x, y) = buffer_position(&output, "1 Preview"); + let (x, y) = buffer_position(&output, "Preview "); let selected_before = app.selected_row_for_test(); app.mouse_scroll(x, y + 2, true); 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)) + ); + } + } +} From 4669a646ee63e8c52b2a7ca7b9038b2371a98db4 Mon Sep 17 00:00:00 2001 From: Martin Zeman Date: Sun, 12 Jul 2026 12:41:57 +0200 Subject: [PATCH 13/22] =?UTF-8?q?feat:=20council=20round=20two=20=E2=80=94?= =?UTF-8?q?=20one=20artifact=20view,=20structured=20provenance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ADRs and skill companions render through the same tabbed artifact view as scanned artifacts: preview, code, diff, provenance, frontmatter, history — synthesized on demand and cached - companions appear as indented child rows under their parent skill - provenance is a structured view: aligned keys, shortened shas, empty fields omitted, per-target ✓/✗ badges with per-harness rows - a rule separates file properties from content in the preview header - o/O open gitui/jjui from ADR and companion rows too; m starts a comment straight from the list; the status bar counts unsaved comments --- src/services/mod.rs | 3 +- src/services/source.rs | 2 +- src/services/target.rs | 2 +- src/tui/app.rs | 368 ++++++++++++++++++++++++++++++++--------- src/tui/tests.rs | 2 +- 5 files changed, 296 insertions(+), 81 deletions(-) diff --git a/src/services/mod.rs b/src/services/mod.rs index 0935c8d..17dc437 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -23,7 +23,8 @@ pub use history::{ extract_frontmatter_field, git_log_for_artifact, read_source_adoption, read_source_sidecar, source_at_deploy, }; -pub use source::strip_frontmatter; +pub use source::{parse_frontmatter, strip_frontmatter}; +pub use target::git_log_in_repo; use crate::error::{Error, ErrorKind}; use crate::provider::ContentKind; diff --git a/src/services/source.rs b/src/services/source.rs index 105b873..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; diff --git a/src/services/target.rs b/src/services/target.rs index 7ad4420..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) diff --git a/src/tui/app.rs b/src/tui/app.rs index 3088ae9..daf0af5 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -22,8 +22,8 @@ use commands::{ files::{self, FileSections}, }, view::{ - Adr, ArtifactView, DashboardView, ModuleView, ProvenanceArtifact, StatusSummary, VcsState, - WorktreeState, + Adr, ArtifactView, Companion, DashboardView, ModuleView, ProvenanceArtifact, StatusSummary, + VcsState, WorktreeState, }, }; @@ -294,6 +294,12 @@ enum ListTarget { Overview, /// The Overview Nested/Matrix mode row: activating it toggles the mode. OverviewMode, + /// 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, @@ -494,6 +500,9 @@ pub struct App { list_last_selected: usize, /// Second-press confirmation state for quitting with unsaved comments. quit_armed: bool, + /// 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, @@ -569,6 +578,7 @@ impl App { list_offset: 0, list_last_selected: 0, quit_armed: false, + synthesized: None, search_typing: false, } } @@ -736,8 +746,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, @@ -892,11 +907,22 @@ impl App { } Some(ListTarget::Adr { repo, id }) => { if let Some(adr) = self.find_adr(&repo, &id).cloned() { - self.render_adr_rich(frame, inner, &adr); + 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, self.detail_scroll); @@ -1003,9 +1029,25 @@ impl App { /// Full ADR document rendered through the markdown pipeline, replacing the /// one-paragraph summary that used to cut the body off. - fn render_adr_rich(&mut self, frame: &mut Frame<'_>, area: Rect, adr: &Adr) { - let cache_width = area.width.max(1); - let key = format!("adr:{}", adr.local_path); + /// 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.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() @@ -1014,7 +1056,7 @@ impl App { if self.preview_cache.is_some() && input_pending() { frame.render_widget( Paragraph::new("rendering…").style(Style::default().fg(Color::DarkGray)), - area, + chunks[1], ); return; } @@ -1025,35 +1067,132 @@ impl App { { self.detail_scroll = 0; } - let raw = std::fs::read_to_string(&adr.local_path) - .unwrap_or_else(|error| format!("could not read {}: {error}", adr.local_path)); - let body = commands::services::strip_frontmatter(&raw); - let mut lines = vec![ - Line::from(Span::styled( - format!("{} {}", adr.id, adr.title), - Style::default().add_modifier(Modifier::BOLD), - )), - Line::from(format!( - "{} · {} · {} · {}", - adr.repo, adr.state, adr.status, adr.relative_path - )), - Line::from(""), - ]; - let rendered = rich::render_markdown_with_glow(&body, cache_width); - let windowed = rendered.is_some(); - match rendered { - Some(rendered) => lines.extend(rendered), - None => lines.extend(body.lines().map(|line| Line::from(line.to_string()))), + 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); self.preview_cache = Some(DetailCache { key, width: cache_width, lines, windowed, - hunks: Vec::new(), + hunks, }); } - self.render_cached_detail(frame, area); + 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 @@ -1693,8 +1832,11 @@ impl App { let name = match self.selected_target() { Some(ListTarget::Module(name)) => name, Some( - ListTarget::Artifact { module, .. } | ListTarget::ProvenanceArtifact { module, .. }, + 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; @@ -1980,6 +2122,11 @@ impl App { KeyCode::Char('m') if self.section == Section::Overview => { 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(); + } _ => {} } } @@ -2155,6 +2302,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 @@ -2536,26 +2695,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() @@ -2563,51 +2738,29 @@ 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))); + lines.extend(field_if("license", &adoption.license)); + lines.extend(field_if("adopted by", &adoption.adopted_by)); } else { - lines.push(Line::from("Upstream: authored source")); + lines.push(field("upstream", "authored here".to_string())); } - lines.push(Line::from(format!("source module: {}", module.name))); - lines.push(Line::from( - "assemble: current binary metadata available in dashboard", - )); + lines.push(field("source", module.name.clone())); + let entries = self.provenance_entries(module, artifact); let groups = builders::group_deployments(&entries); - if groups.is_empty() { - lines.push(Line::from("deploy groups: none")); - } 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(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 @@ -3106,6 +3259,11 @@ fn preview_lines_for_width(artifact: &ArtifactView, width: u16) -> (Vec 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", + 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 +} + /// Row offsets of hunk headers within rendered diff lines. fn hunk_offsets(lines: &[Line<'_>]) -> Vec { lines diff --git a/src/tui/tests.rs b/src/tui/tests.rs index c94a531..cb1abba 100644 --- a/src/tui/tests.rs +++ b/src/tui/tests.rs @@ -239,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); From 37fffedf32e90288ff7cb6c53924aeaac9230725 Mon Sep 17 00:00:00 2001 From: Martin Zeman Date: Sun, 12 Jul 2026 12:46:37 +0200 Subject: [PATCH 14/22] =?UTF-8?q?feat:=20council=20round=20three=20?= =?UTF-8?q?=E2=80=94=20code=20cursor,=20exact=20scroll=20everywhere?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the Code tab gains a line cursor decoupled from the viewport: j/k and paging move it and the viewport follows only when it exits; the wheel scrolls without touching it; m comments the cursor line; g/G jump it - paging is sized to the pane (PgUp/PgDn, space/b) plus Ctrl-d/Ctrl-u - re-pressing the active tab key no longer resets the scroll position - the Detail title shows position n/N; clicking the selected row drills - Provenance, History, and Companions pre-wrap like the other tabs, so scroll clamping is exact everywhere except the rare plain fallback --- src/tui/app.rs | 128 ++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 105 insertions(+), 23 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index daf0af5..b78f810 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -7,7 +7,7 @@ use std::{ thread, }; -use crossterm::event::{KeyCode, KeyEvent}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::{ Frame, layout::{Constraint, Direction, Layout, Position, Rect}, @@ -500,6 +500,11 @@ pub struct App { 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. + code_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)>, @@ -578,6 +583,8 @@ impl App { list_offset: 0, list_last_selected: 0, quit_armed: false, + code_cursor: 0, + detail_viewport: 1, synthesized: None, search_typing: false, } @@ -887,7 +894,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.code_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); @@ -986,6 +1009,7 @@ impl App { self.prepare_artifact_detail_cache(module_index, artifact_index, chunks[1].width); 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 @@ -1199,6 +1223,7 @@ impl App { /// 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() @@ -1259,6 +1284,7 @@ impl App { .is_none_or(|cache| cache.path != key); if needs_build { self.detail_scroll = 0; + self.code_cursor = 0; let lines = rich::highlight_code(&artifact.relative_path, &artifact.raw_source); self.code_cache = Some(CodeCache { path: key, lines }); #[cfg(test)] @@ -1338,15 +1364,25 @@ impl App { true, ), DetailTab::Provenance => ( - module.map_or_else( - || vec![Line::from("module not found")], - |module| self.provenance_lines(module, artifact), + expand_gutter_wrapped( + module.map_or_else( + || vec![Line::from("module not found")], + |module| self.provenance_lines(module, artifact), + ), + 2, + usize::from(width), ), - false, + true, ), DetailTab::Frontmatter => (frontmatter_lines(artifact, width), true), - DetailTab::History => (history_lines(artifact), false), - DetailTab::Companions => (companion_lines(artifact), false), + DetailTab::History => ( + expand_gutter_wrapped(history_lines(artifact), 2, usize::from(width)), + true, + ), + DetailTab::Companions => ( + expand_gutter_wrapped(companion_lines(artifact), 2, usize::from(width)), + true, + ), } } @@ -1653,6 +1689,22 @@ 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 self.detail_tab == DetailTab::Code { + self.move_code_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 { @@ -1758,9 +1810,13 @@ impl App { .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(); } } } @@ -1865,6 +1921,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; } @@ -2132,21 +2191,28 @@ impl App { } 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::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::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::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.detail_step(-half); + } + KeyCode::Home | KeyCode::Char('g') => { + self.code_cursor = 0; + self.detail_scroll = 0; } - KeyCode::PageUp => { - self.detail_scroll = self.detail_scroll.saturating_sub(10); + KeyCode::End | KeyCode::Char('G') => { + self.code_cursor = usize::MAX; + self.detail_scroll = u16::MAX; + self.move_code_cursor(0); } - KeyCode::Home | KeyCode::Char('g') => self.detail_scroll = 0, - KeyCode::End | KeyCode::Char('G') => self.detail_scroll = u16::MAX, 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), @@ -2606,9 +2672,25 @@ 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.code_cursor.saturating_add(1).min(line_count) + } + + /// Moves the Code cursor and drags the viewport along only when the + /// cursor leaves it. + fn move_code_cursor(&mut self, delta: isize) { + let total = self + .code_cache + .as_ref() + .map_or(1, |cache| cache.lines.len().max(1)); + let cursor = self.code_cursor.saturating_add_signed(delta).min(total - 1); + self.code_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( From fbc9214cc2abf86c370766d180fc2b9607073140 Mon Sep 17 00:00:00 2001 From: Martin Zeman Date: Sun, 12 Jul 2026 13:33:49 +0200 Subject: [PATCH 15/22] feat: diff line-number gutter and per-line diff commenting - diff rows carry an old/new number gutter parsed from hunk headers; untracked bodies are numbered too - the detail cursor works in the Diff tab: j/k and paging move it with the viewport following, hunk jumps land it on the header, the row highlights - m on a diff row comments the mapped new-file source line in place; header and deletion rows decline with a toast instead of mis-anchoring --- src/tui/app.rs | 209 ++++++++++++++++++++++++++++++++++++++++------- src/tui/tests.rs | 14 ++++ 2 files changed, 193 insertions(+), 30 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index b78f810..930e381 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -405,6 +405,8 @@ struct DetailCache { 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>, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -502,7 +504,7 @@ pub struct App { quit_armed: bool, /// Line cursor for the Code tab, decoupled from the viewport: keys move /// it (viewport follows), the wheel scrolls without touching it. - code_cursor: usize, + 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 @@ -583,7 +585,7 @@ impl App { list_offset: 0, list_last_selected: 0, quit_armed: false, - code_cursor: 0, + detail_cursor: 0, detail_viewport: 1, synthesized: None, search_typing: false, @@ -898,7 +900,7 @@ impl App { DetailTab::Code => self .code_cache .as_ref() - .map(|cache| (self.code_cursor + 1, cache.lines.len())), + .map(|cache| (self.detail_cursor + 1, cache.lines.len())), _ => self .preview_cache .as_ref() @@ -1109,12 +1111,18 @@ impl App { 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() + }; self.preview_cache = Some(DetailCache { key, width: cache_width, lines, windowed, hunks, + line_map, }); } self.render_cached_detail(frame, chunks[1]); @@ -1235,7 +1243,15 @@ impl App { .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 lines = self.preview_window(viewport); + 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 @@ -1284,7 +1300,7 @@ impl App { .is_none_or(|cache| cache.path != key); if needs_build { self.detail_scroll = 0; - self.code_cursor = 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)] @@ -1319,6 +1335,7 @@ impl App { .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]; @@ -1326,12 +1343,19 @@ impl App { 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() + }; + 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, }); #[cfg(test)] { @@ -1360,7 +1384,7 @@ impl App { true, ), DetailTab::Diff => ( - expand_gutter_wrapped(diff_lines(module, artifact, width), 1, usize::from(width)), + expand_gutter_wrapped(diff_lines(module, artifact, width), 10, usize::from(width)), true, ), DetailTab::Provenance => ( @@ -1692,8 +1716,8 @@ 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 self.detail_tab == DetailTab::Code { - self.move_code_cursor(delta); + 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 @@ -1718,6 +1742,7 @@ impl App { }; if let Some(&offset) = target { self.detail_scroll = u16::try_from(offset).unwrap_or(u16::MAX); + self.detail_cursor = offset; } } @@ -2114,12 +2139,29 @@ 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 module = artifact.module.clone(); - 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(&(module.clone(), path.clone(), line_number)) @@ -2205,13 +2247,13 @@ impl App { self.detail_step(-half); } KeyCode::Home | KeyCode::Char('g') => { - self.code_cursor = 0; + self.detail_cursor = 0; self.detail_scroll = 0; } KeyCode::End | KeyCode::Char('G') => { - self.code_cursor = usize::MAX; + self.detail_cursor = usize::MAX; self.detail_scroll = u16::MAX; - self.move_code_cursor(0); + 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), @@ -2224,7 +2266,7 @@ impl App { KeyCode::Char('n') => self.set_detail_tab(DetailTab::Companions), KeyCode::Tab => self.next_detail_tab(), KeyCode::Char('m') => { - if self.detail_tab != DetailTab::Code { + if !matches!(self.detail_tab, DetailTab::Code | DetailTab::Diff) { self.set_detail_tab(DetailTab::Code); } self.open_comment_prompt(); @@ -2672,18 +2714,27 @@ impl App { fn current_code_line(&self, artifact: &ArtifactView) -> usize { let line_count = artifact.raw_source.lines().count().max(1); - self.code_cursor.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_code_cursor(&mut self, delta: isize) { - let total = self - .code_cache - .as_ref() - .map_or(1, |cache| cache.lines.len().max(1)); - let cursor = self.code_cursor.saturating_add_signed(delta).min(total - 1); - self.code_cursor = cursor; + 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 { @@ -3407,11 +3458,14 @@ fn diff_lines( Line::default(), ]; match std::fs::read_to_string(repo.join(path)) { - Ok(body) => lines.extend(body.lines().map(|line| { - Line::from(Span::styled( - format!("+{line}"), - Style::default().fg(Color::Green), - )) + 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}"))), } @@ -3443,18 +3497,113 @@ fn diff_lines( } 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(diff_line_colored(raw)); + 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() +} + /// The Deployments block of the provenance view: per-target verification /// badges with per-harness rows. fn deployment_lines(groups: &[commands::view::DeployGroup]) -> Vec> { diff --git a/src/tui/tests.rs b/src/tui/tests.rs index cb1abba..3dc50fd 100644 --- a/src/tui/tests.rs +++ b/src/tui/tests.rs @@ -503,6 +503,20 @@ fn comment_prompt_opens_from_preview_tab() { 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); From fcfd16c0ba644e0df883ab98f46ca22865d01c60 Mon Sep 17 00:00:00 2001 From: Martin Zeman Date: Sun, 12 Jul 2026 14:43:22 +0200 Subject: [PATCH 16/22] feat: numbered tabs restored, glow color unlocked, richer style, repo READMEs - the tab bar shows numbers again and digits follow focus: sections while browsing, detail tabs 1-7 when the detail pane or zoom has focus - glow was silently dropping every color because it saw a pipe instead of a terminal; CLICOLOR_FORCE and COLORTERM restore full-color previews - the glamour style differentiates heading levels, styles quotes, links, emphasis, and rules - repository detail renders the repo README through glow below the header and commit log, cached and scrollable like every other detail view --- src/tui/app.rs | 109 +++++++++++++++++++++++++++------- src/tui/components/preview.rs | 2 +- src/tui/event.rs | 12 ++-- src/tui/glow_style.json | 47 ++++++++++----- src/tui/rich.rs | 4 ++ src/tui/tests.rs | 9 +-- 6 files changed, 138 insertions(+), 45 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 930e381..6c9cd5c 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -949,11 +949,7 @@ impl App { 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, self.detail_scroll); - } else { - frame.render_widget(Paragraph::new("repository not found"), inner); - } + self.render_module_rich(frame, inner, &name); } Some(ListTarget::Variant { module, @@ -1055,6 +1051,68 @@ impl App { /// 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; + }; + 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); + 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)); + self.preview_cache = Some(DetailCache { + key, + width: cache_width, + lines, + windowed: true, + hunks: Vec::new(), + line_map: Vec::new(), + }); + } + 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. @@ -1456,7 +1514,8 @@ impl App { fn render_tabs(&self, frame: &mut Frame<'_>, area: Rect) { let spans = DetailTab::ALL .iter() - .flat_map(|tab| { + .enumerate() + .flat_map(|(index, tab)| { let style = if *tab == self.detail_tab { Style::default() .fg(Color::Black) @@ -1465,7 +1524,10 @@ impl App { } else { Style::default().fg(Color::DarkGray) }; - [Span::raw(" "), Span::styled(tab.label(), style)] + [ + Span::raw(" "), + Span::styled(format!("{} {}", index + 1, tab.label()), style), + ] }) .collect::>(); frame.render_widget(Paragraph::new(Line::from(spans)), area); @@ -1937,6 +1999,13 @@ impl App { self.pending_external.take() } + /// Digits address detail tabs while the detail pane has focus; sections + /// otherwise. + #[must_use] + pub fn detail_digits_active(&self) -> bool { + self.focused == ColumnFocus::Detail + } + pub fn set_section_by_shortcut(&mut self, character: char) -> bool { let Some(section) = Section::from_shortcut(character) else { return false; @@ -2246,6 +2315,10 @@ impl App { KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { self.detail_step(-half); } + KeyCode::Char(digit @ '1'..='7') => { + 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; @@ -2929,7 +3002,7 @@ fn sidecar_yaml_lines(module: &ModuleView, artifact: &ArtifactView) -> Vec, area: Rect, module: &ModuleView, scroll: u16) { +fn module_header_lines(module: &ModuleView) -> Vec> { let mut lines = vec![ Line::from(Span::styled( module.name.clone(), @@ -2984,12 +3057,7 @@ fn render_module_detail(frame: &mut Frame<'_>, area: Rect, module: &ModuleView, Style::default().fg(Color::DarkGray), ))); } - frame.render_widget( - Paragraph::new(Text::from(lines)) - .wrap(Wrap { trim: false }) - .scroll((scroll, 0)), - area, - ); + lines } fn module_vcs_line(vcs: &VcsState) -> Line<'static> { @@ -3254,11 +3322,12 @@ fn bordered_row_at(region: Rect, x: u16, y: u16) -> Option { /// snaps to that tab so there are no dead cells between targets. fn tab_at_column(column: u16) -> Option { let mut cursor = 0u16; - for tab in DetailTab::ALL { - let width = u16::try_from(tab.label().chars().count()).unwrap_or(u16::MAX); + 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); + return Some(*tab); } cursor = end; } @@ -3268,10 +3337,10 @@ fn tab_at_column(column: u16) -> Option { fn hint_row(focused: ColumnFocus) -> String { if focused == ColumnFocus::Detail { return [ - "Tab/p c d v f i n tabs", - "1-9 sections", - "j/k scroll", + "1-7/Tab tabs", + "j/k move", "m comment", + "[ ] hunks", "Y copy review", "? help", ] diff --git a/src/tui/components/preview.rs b/src/tui/components/preview.rs index 1134f12..b4fa775 100644 --- a/src/tui/components/preview.rs +++ b/src/tui/components/preview.rs @@ -117,7 +117,7 @@ impl ArtifactPreview { let block = Block::default() .title(title) .title_bottom(Line::from(Span::styled( - " p/c/d/v/f/i/n tabs · j/k · ␣/b page · g/G ends · Esc close ", + " 1-7 tabs · j/k · ␣/b page · g/G ends · Esc close ", Style::default().fg(Color::DarkGray), ))) .borders(Borders::ALL) diff --git a/src/tui/event.rs b/src/tui/event.rs index 342f934..c1974b4 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -44,9 +44,11 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { app.search_input_key(key); return; } - // Digits switch sections from any focus; letter shortcuts (t/h/c/m) + // Digits switch sections while browsing; with the detail pane focused + // they address its numbered tabs instead. Letter shortcuts (t/h/c/m) // stay scoped to the Sections column where they are advertised. - if let KeyCode::Char(character @ '0'..='9') = key.code + if !app.detail_digits_active() + && let KeyCode::Char(character @ '0'..='9') = key.code && app.set_section_by_shortcut(character) { return; @@ -100,9 +102,9 @@ 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 @ '0'..='9') => { - app.close_preview(); - app.set_section_by_shortcut(digit); + KeyCode::Char(digit @ '1'..='7') => { + 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), diff --git a/src/tui/glow_style.json b/src/tui/glow_style.json index 96100ef..61bf00e 100644 --- a/src/tui/glow_style.json +++ b/src/tui/glow_style.json @@ -7,7 +7,9 @@ }, "block_quote": { "indent": 1, - "indent_token": "\u2502 " + "indent_token": "\u2503 ", + "color": "109", + "italic": true }, "paragraph": {}, "list": { @@ -26,19 +28,28 @@ "bold": true }, "h2": { - "prefix": "## " + "prefix": "", + "color": "45", + "bold": true, + "underline": true }, "h3": { - "prefix": "### " + "prefix": "", + "color": "74", + "bold": true }, "h4": { - "prefix": "#### " + "prefix": "", + "color": "30", + "bold": true }, "h5": { - "prefix": "##### " + "prefix": "", + "color": "66", + "bold": true }, "h6": { - "prefix": "###### ", + "prefix": "", "color": "35", "bold": false }, @@ -47,31 +58,35 @@ "crossed_out": true }, "emph": { - "italic": true + "italic": true, + "color": "252" }, "strong": { - "bold": true + "bold": true, + "color": "229" }, "hr": { "color": "240", - "format": "\n--------\n" + "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 " + "block_prefix": "\u2022 ", + "color": "252" }, "enumeration": { - "block_prefix": ". " + "block_prefix": ". ", + "color": "252" }, "task": { "ticked": "[\u2713] ", "unticked": "[ ] " }, "link": { - "color": "30", + "color": "32", "underline": true }, "link_text": { - "color": "35", + "color": "45", "bold": true }, "image": { @@ -180,7 +195,9 @@ } } }, - "table": {}, + "table": { + "color": "252" + }, "definition_list": {}, "definition_term": {}, "definition_description": { @@ -188,4 +205,4 @@ }, "html_block": {}, "html_span": {} -} \ No newline at end of file +} diff --git a/src/tui/rich.rs b/src/tui/rich.rs index feff0b4..dd1cdb6 100644 --- a/src/tui/rich.rs +++ b/src/tui/rich.rs @@ -63,6 +63,10 @@ fn glow_lines(text: &str, width: u16) -> Option>> { let style = glow_style_path()?; let mut child = Command::new("glow") .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()) diff --git a/src/tui/tests.rs b/src/tui/tests.rs index 3dc50fd..8f2e393 100644 --- a/src/tui/tests.rs +++ b/src/tui/tests.rs @@ -363,16 +363,17 @@ fn search_input_mode_is_explicit() { } #[test] -fn digit_switches_section_from_any_focus_and_letter_switches_tab() { +fn digits_follow_focus_sections_from_list_tabs_from_detail() { let mut app = fixture_app(); app.focus_next(); - app.focus_next(); event::handle_key(&mut app, key(KeyCode::Char('2'))); assert_eq!(app.section(), Section::Skills); - event::handle_key(&mut app, key(KeyCode::Char('c'))); - assert_eq!(app.detail_tab(), DetailTab::Code); + app.focus_next(); + event::handle_key(&mut app, key(KeyCode::Char('3'))); + assert_eq!(app.detail_tab(), DetailTab::Diff); + assert_eq!(app.section(), Section::Skills); } #[test] From 79681154fafeccd51671d8bf2edcc832e751611e Mon Sep 17 00:00:00 2001 From: Martin Zeman Date: Sun, 12 Jul 2026 14:48:17 +0200 Subject: [PATCH 17/22] feat: deploy-to-target picker and harness launch in the TUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - D on an artifact, companion, ADR, or repository opens a target picker (user scope, this project, watched locations); Enter suspends the TUI and runs install --source --target --no-prune, then rescans — additive deploy, nothing at the target is pruned - L suspends into a harness session in the module repo: FORGE_TUI_LAUNCH when set, the claude CLI otherwise, forge launch once this binary has it - external commands generalized to program + args + directory --- src/tui/app.rs | 195 +++++++++++++++++++++++++++++++++++++++++++++-- src/tui/event.rs | 6 ++ src/tui/mod.rs | 11 +-- src/tui/tests.rs | 37 +++++++++ 4 files changed, 239 insertions(+), 10 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 6c9cd5c..b9b654d 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -91,6 +91,8 @@ pub const KEYBINDINGS: &[(&str, &[(&str, &str)])] = &[ ("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")]), @@ -382,6 +384,23 @@ 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. +#[derive(Debug, Clone)] +struct DeployPicker { + module_name: String, + source: PathBuf, + options: Vec<(String, PathBuf)>, + 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)] @@ -493,9 +512,11 @@ pub struct App { help_state: HelpState, palette: Palette, mouse_regions: MouseRegions, - /// External TUI (gitui/jjui) queued to run in a repo; the event loop - /// suspends the terminal, runs it, and resumes. - pending_external: Option<(String, PathBuf)>, + /// 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, /// First visible row of the list column (viewport scroll offset). list_offset: usize, /// Selection seen at the last render, to detect selection movement. @@ -582,6 +603,7 @@ impl App { palette: Palette::new(), mouse_regions: MouseRegions::default(), pending_external: None, + deploy_picker: None, list_offset: 0, list_last_selected: 0, quit_armed: false, @@ -743,6 +765,9 @@ impl App { 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 self.help_state == HelpState::Open { render_help(frame, frame.area()); } @@ -1992,13 +2017,122 @@ impl App { self.toast = Some(format!("{program}: no local clone for {name}")); return; }; - self.pending_external = Some((program.to_string(), path)); + self.pending_external = Some(ExternalCommand { + program: program.to_string(), + args: Vec::new(), + directory: path, + }); } - pub fn take_external(&mut self) -> Option<(String, PathBuf)> { + 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)) + } + + /// Opens the deploy target picker for the selected artifact's module. + 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 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 { + module_name, + source, + options, + selected: 0, + }); + } + + #[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 the + /// module to the chosen target additively (install --no-prune), Esc closes. + pub fn deploy_picker_key(&mut self, key: KeyEvent) { + let Some(picker) = self.deploy_picker.as_mut() else { + return; + }; + match key.code { + KeyCode::Esc | KeyCode::Char('q') => self.deploy_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.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()); + self.pending_external = Some(ExternalCommand { + program, + args: vec![ + "install".to_string(), + "--source".to_string(), + picker.source.display().to_string(), + "--target".to_string(), + target.display().to_string(), + "--no-prune".to_string(), + ], + directory: picker.source.clone(), + }); + self.toast = Some(format!("deploying {} …", picker.module_name)); + } + _ => {} + } + } + + /// Suspends into a harness session in the selected module's repo. Uses + /// `FORGE_TUI_LAUNCH` when set, `forge launch` once this binary grows it, + /// otherwise the claude CLI. + 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 program = std::env::var("FORGE_TUI_LAUNCH").unwrap_or_else(|_| "claude".to_string()); + self.toast = Some(format!("launching {program} in {module_name}")); + self.pending_external = Some(ExternalCommand { + program, + args: Vec::new(), + directory: path, + }); + } + /// Digits address detail tabs while the detail pane has focus; sections /// otherwise. #[must_use] @@ -2028,6 +2162,18 @@ 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 { @@ -3334,6 +3480,45 @@ fn tab_at_column(column: u16) -> Option { None } +/// Centered modal listing deploy targets for a module. +fn render_deploy_picker(frame: &mut Frame<'_>, area: Rect, picker: &DeployPicker) { + let height = u16::try_from(picker.options.len()) + .unwrap_or(u16::MAX) + .saturating_add(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(format!(" Deploy {} to… ", picker.module_name)) + .title_bottom(Line::from(Span::styled( + " j/k select · Enter deploy (additive) · Esc cancel ", + 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 items: Vec> = picker + .options + .iter() + .enumerate() + .map(|(index, (label, _))| { + let style = if index == picker.selected { + selected_style(true) + } else { + Style::default() + }; + ListItem::new(Line::from(Span::styled(label.clone(), style))) + }) + .collect(); + frame.render_widget(List::new(items), inner); +} + fn hint_row(focused: ColumnFocus) -> String { if focused == ColumnFocus::Detail { return [ diff --git a/src/tui/event.rs b/src/tui/event.rs index c1974b4..4526978 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -35,6 +35,10 @@ 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_search_input_active() && matches!( key.code, @@ -89,6 +93,8 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { KeyCode::Char('n') => app.set_detail_tab(super::app::DetailTab::Companions), 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), } } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 866be83..520f564 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -171,8 +171,8 @@ fn event_loop(terminal: &mut TuiTerminal, app: &mut App) -> Result<(), Box {} } } - if let Some((program, directory)) = app.take_external() { - run_external_tool(terminal, app, &program, &directory)?; + if let Some(command) = app.take_external() { + run_external_tool(terminal, app, &command)?; } } Ok(()) @@ -183,12 +183,13 @@ fn event_loop(terminal: &mut TuiTerminal, app: &mut App) -> Result<(), Box Result<(), Box> { + let program = &command.program; restore_terminal(terminal); let status = std::process::Command::new(program) - .current_dir(directory) + .args(&command.args) + .current_dir(&command.directory) .status(); *terminal = setup_terminal()?; terminal.clear()?; diff --git a/src/tui/tests.rs b/src/tui/tests.rs index 8f2e393..f8f581e 100644 --- a/src/tui/tests.rs +++ b/src/tui/tests.rs @@ -490,6 +490,43 @@ fn mouse_wheel_scrolls_detail_without_moving_selection() { 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())); +} + +#[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'))); + + let command = app.take_external().expect("launch queued"); + assert_eq!(command.directory, PathBuf::from("/tmp/forge-core")); + assert!(command.args.is_empty()); +} + #[test] fn comment_prompt_opens_from_preview_tab() { let mut app = fixture_app(); From 7a395f1526efb232645c13cd7acd86443e1faf25 Mon Sep 17 00:00:00 2001 From: Martin Zeman Date: Sun, 12 Jul 2026 15:21:41 +0200 Subject: [PATCH 18/22] feat: single-artifact deploy, add-target input, harness launch picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - install/deploy gain --only : deploys just the files under a module-relative prefix and never prunes, so adding one artifact to a target leaves everything else there untouched - D on an artifact deploys exactly that artifact (skills carry their companions, agents/rules match across provider extensions); on a repository row it deploys the whole module — the picker title says which - the picker ends with an add-target row: type a path (~ expands, must be an existing directory), it persists to the watchlist and becomes a selectable target immediately - L opens a harness picker (FORGE_TUI_LAUNCH first when set, then claude, codex, gemini, opencode) and suspends into the session in the module repo --- src/cli/deploy/mod.rs | 10 ++ src/cli/deploy/tests.rs | 35 +++++ src/cli/install/mod.rs | 2 + src/cli/install/tests.rs | 5 + src/cli/mod.rs | 25 +++- src/cli/release/mod.rs | 1 + src/tui/app.rs | 287 ++++++++++++++++++++++++++++++++++----- src/tui/event.rs | 4 + src/tui/tests.rs | 4 + 9 files changed, 338 insertions(+), 35 deletions(-) diff --git a/src/cli/deploy/mod.rs b/src/cli/deploy/mod.rs index 688960b..d5ec739 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(); @@ -103,6 +108,7 @@ pub fn execute( &mut result, provider_name, force, + only, )?; } @@ -145,6 +151,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 +166,9 @@ fn deploy_provider_kind_files( .to_string_lossy() .to_string(); let manifest_key = format!("{kind}/{relative}"); + if only.is_some_and(|prefix| !manifest_key.starts_with(prefix)) { + continue; + } deployed_keys.insert(manifest_key.clone()); let target_path = target_base.join(kind.as_str()).join(&relative); diff --git a/src/cli/deploy/tests.rs b/src/cli/deploy/tests.rs index fb44876..ae3fc0e 100644 --- a/src/cli/deploy/tests.rs +++ b/src/cli/deploy/tests.rs @@ -201,3 +201,38 @@ 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_files( + &build_dir, + &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" + ); +} 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 387bf42..0a3c204 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -131,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). @@ -185,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) @@ -422,6 +432,7 @@ pub fn run() -> i32 { no_prune, dry_run, allow_stale, + only, model, } => ( install::execute( @@ -432,6 +443,7 @@ pub fn run() -> i32 { !no_prune, interactive, dry_run, + only.as_deref(), model.as_deref(), allow_stale, ), @@ -449,6 +461,7 @@ pub fn run() -> i32 { interactive, no_prune, dry_run, + only, } => ( deploy::execute( &source, @@ -458,6 +471,7 @@ pub fn run() -> i32 { !no_prune, interactive, dry_run, + only.as_deref(), ), "deployed", ), @@ -494,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/tui/app.rs b/src/tui/app.rs index b9b654d..3800cf2 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -392,13 +392,27 @@ pub struct ExternalCommand { pub directory: PathBuf, } -/// Modal target picker for deploying a module. +/// Modal target picker for deploying a module or a single artifact. #[derive(Debug, Clone)] struct DeployPicker { - module_name: String, + /// 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 @@ -517,6 +531,8 @@ pub struct App { 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. @@ -604,6 +620,7 @@ impl App { mouse_regions: MouseRegions::default(), pending_external: None, deploy_picker: None, + launch_picker: None, list_offset: 0, list_last_selected: 0, quit_armed: false, @@ -768,6 +785,9 @@ impl App { 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()); } @@ -2047,12 +2067,44 @@ impl App { Some((name, path)) } - /// Opens the deploy target picker for the selected artifact's module. + /// 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 { + 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)); @@ -2065,10 +2117,12 @@ impl App { options.push((format!("watched: {}", location.display()), location.clone())); } self.deploy_picker = Some(DeployPicker { - module_name, + scope_label, source, + only, options, selected: 0, + input: None, }); } @@ -2077,20 +2131,30 @@ impl App { self.deploy_picker.is_some() } - /// One keypress inside the deploy picker: j/k select, Enter deploys the - /// module to the chosen target additively (install --no-prune), Esc closes. + /// 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(picker.options.len().saturating_sub(1)); + 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 { @@ -2098,41 +2162,123 @@ impl App { }; 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: vec![ - "install".to_string(), - "--source".to_string(), - picker.source.display().to_string(), - "--target".to_string(), - target.display().to_string(), - "--no-prune".to_string(), - ], + args, directory: picker.source.clone(), }); - self.toast = Some(format!("deploying {} …", picker.module_name)); + self.toast = Some(format!("deploying {} …", picker.scope_label)); } _ => {} } } - /// Suspends into a harness session in the selected module's repo. Uses - /// `FORGE_TUI_LAUNCH` when set, `forge launch` once this binary grows it, - /// otherwise the claude CLI. + /// 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; + } + let _ = watchlist::add_path(&path.display().to_string(), true); + 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()); + } + _ => {} + } + } + + /// 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 program = std::env::var("FORGE_TUI_LAUNCH").unwrap_or_else(|_| "claude".to_string()); - self.toast = Some(format!("launching {program} in {module_name}")); - self.pending_external = Some(ExternalCommand { - program, - args: Vec::new(), + let mut options: Vec<(String, String)> = Vec::new(); + if let Ok(custom) = std::env::var("FORGE_TUI_LAUNCH") { + 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() + } + + 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, program)) = picker.options.get(picker.selected) else { + return; + }; + self.toast = Some(format!("launching {label} in {}", picker.module_name)); + self.pending_external = Some(ExternalCommand { + program: program.clone(), + args: Vec::new(), + directory: picker.directory.clone(), + }); + } + _ => {} + } + } + /// Digits address detail tabs while the detail pane has focus; sections /// otherwise. #[must_use] @@ -3480,11 +3626,19 @@ fn tab_at_column(column: u16) -> Option { None } -/// Centered modal listing deploy targets for a module. -fn render_deploy_picker(frame: &mut Frame<'_>, area: Rect, picker: &DeployPicker) { - let height = u16::try_from(picker.options.len()) +/// 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(4); + .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, @@ -3494,21 +3648,20 @@ fn render_deploy_picker(frame: &mut Frame<'_>, area: Rect, picker: &DeployPicker }; frame.render_widget(Clear, popup); let block = Block::default() - .title(format!(" Deploy {} to… ", picker.module_name)) + .title(title.to_string()) .title_bottom(Line::from(Span::styled( - " j/k select · Enter deploy (additive) · Esc cancel ", + 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 items: Vec> = picker - .options + let mut items: Vec> = labels .iter() .enumerate() - .map(|(index, (label, _))| { - let style = if index == picker.selected { + .map(|(index, label)| { + let style = if index == selected && input.is_none() { selected_style(true) } else { Style::default() @@ -3516,9 +3669,51 @@ fn render_deploy_picker(frame: &mut Frame<'_>, area: Rect, picker: &DeployPicker 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() + .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 [ @@ -3665,6 +3860,30 @@ fn preview_lines_for_width(artifact: &ArtifactView, width: u16) -> (Vec String { + if kind == "skills" { + let segments: Vec<&str> = relative_path.split('/').take(2).collect(); + return format!("{}/", segments.join("/")); + } + let stem = relative_path + .rsplit_once('.') + .map_or(relative_path, |(stem, _)| stem); + format!("{stem}.") +} + +/// Expands a leading `~` to the home directory. +fn expand_home(raw: &str) -> PathBuf { + 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 { diff --git a/src/tui/event.rs b/src/tui/event.rs index 4526978..fbae489 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -39,6 +39,10 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { 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, diff --git a/src/tui/tests.rs b/src/tui/tests.rs index f8f581e..6f15cb5 100644 --- a/src/tui/tests.rs +++ b/src/tui/tests.rs @@ -511,6 +511,8 @@ fn deploy_picker_queues_additive_install_for_selected_module() { 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] @@ -521,6 +523,8 @@ fn launch_queues_harness_in_module_repo() { 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")); From 84e1a6035bcd0ff65e19ec1a59cdca84a4266446 Mon Sep 17 00:00:00 2001 From: Martin Zeman Date: Sun, 12 Jul 2026 15:25:16 +0200 Subject: [PATCH 19/22] =?UTF-8?q?fix:=20deploy/launch=20council=20round=20?= =?UTF-8?q?=E2=80=94=20slug-safe=20--only,=20contained=20writes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - --only matching survives provider renames: keys and prefixes compare lowercased with separators dropped, so gemini/opencode slugs still match - every content write validates its deepest existing ancestor against the target base, so a symlinked directory under the target cannot redirect a deploy outside it; boundary validation runs before any directory exists - provenance sidecars copy only alongside content that actually installed; a locally-modified skipped file keeps its previous sidecar - a corrupt target manifest aborts filtered deploys instead of silently rewriting the manifest down to the filtered keys; full deploys warn - the add-target row reports watchlist failures instead of claiming success, and persistence no longer prints inside the alternate screen - open pickers block mouse events from reaching the panes underneath - custom launch commands split into program and arguments --- src/cli/deploy/mod.rs | 143 +++++++++++++++++++++++++++++++++------ src/cli/deploy/tests.rs | 44 ++++++++++-- src/cli/drift/scope.rs | 5 +- src/cli/watchlist/mod.rs | 17 +++-- src/tui/app.rs | 63 +++++++++++++---- src/tui/event.rs | 3 + 6 files changed, 230 insertions(+), 45 deletions(-) diff --git a/src/cli/deploy/mod.rs b/src/cli/deploy/mod.rs index d5ec739..f69670f 100644 --- a/src/cli/deploy/mod.rs +++ b/src/cli/deploy/mod.rs @@ -77,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 { @@ -94,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( @@ -140,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( @@ -166,7 +197,7 @@ fn deploy_provider_kind_files( .to_string_lossy() .to_string(); let manifest_key = format!("{kind}/{relative}"); - if only.is_some_and(|prefix| !manifest_key.starts_with(prefix)) { + if only.is_some_and(|prefix| !only_matches(&manifest_key, prefix)) { continue; } deployed_keys.insert(manifest_key.clone()); @@ -177,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(), @@ -191,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 { @@ -246,7 +284,6 @@ fn deploy_provider_kind_files( } Ok(()) } - #[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_lines)] fn prune_stale_files( @@ -444,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, @@ -481,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()) } } } @@ -518,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 ae3fc0e..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"); } @@ -215,8 +215,9 @@ fn deploy_provider_files_only_prefix_filters_deployment() { let mut manifest_entries = HashMap::new(); let mut deployed_keys = HashSet::new(); let mut result = ActionResult::new(); - deploy_provider_files( - &build_dir, + deploy_provider_kind_files( + &build_dir.join("skills"), + commands::provider::ContentKind::Skills, &target, &mut manifest_entries, &mut deployed_keys, @@ -236,3 +237,38 @@ fn deploy_provider_files_only_prefix_filters_deployment() { "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/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/tui/app.rs b/src/tui/app.rs index 3800cf2..2ac7bc2 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -2082,7 +2082,7 @@ impl App { } ListTarget::Companion { parent, name, .. } => Some(( format!("companion {parent}/{name}"), - Some(format!("skills/{parent}/{name}.")), + Some(format!("skills/{parent}/{name}")), )), ListTarget::Module(name) => Some((format!("module {name}"), None)), ListTarget::Adr { repo, .. } => Some((format!("module {repo}"), None)), @@ -2098,6 +2098,7 @@ impl App { 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() { @@ -2212,12 +2213,18 @@ impl App { self.toast = Some(format!("not a directory: {raw}")); return; } - let _ = watchlist::add_path(&path.display().to_string(), true); - 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()); + 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}")); + } + } } _ => {} } @@ -2232,7 +2239,9 @@ impl App { return; }; let mut options: Vec<(String, String)> = Vec::new(); - if let Ok(custom) = std::env::var("FORGE_TUI_LAUNCH") { + 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"] { @@ -2251,6 +2260,17 @@ impl App { 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; @@ -2265,13 +2285,19 @@ impl App { } KeyCode::Enter => { let picker = self.launch_picker.take().expect("picker is open"); - let Some((label, program)) = picker.options.get(picker.selected) else { + 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: program.clone(), - args: Vec::new(), + program, + args: words.collect(), directory: picker.directory.clone(), }); } @@ -3657,9 +3683,13 @@ fn render_choice_popup( .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) @@ -3868,14 +3898,19 @@ fn artifact_only_prefix(kind: &str, relative_path: &str) -> String { let segments: Vec<&str> = relative_path.split('/').take(2).collect(); return format!("{}/", segments.join("/")); } - let stem = relative_path + relative_path .rsplit_once('.') - .map_or(relative_path, |(stem, _)| stem); - format!("{stem}.") + .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() { diff --git a/src/tui/event.rs b/src/tui/event.rs index fbae489..98fc7af 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -3,6 +3,9 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKi 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(); From 0b577c1fb970a1a9cc2d054d484f7c631c8efee2 Mon Sep 17 00:00:00 2001 From: Martin Zeman Date: Sun, 12 Jul 2026 16:05:43 +0200 Subject: [PATCH 20/22] feat: digits address tabs only, jj view, clickable commits, one companion surface - digits 1-6 switch the detail tabs from any focus and nothing else; the sections column drops its confusing numbers (letters t/h/c/m remain) - the Companions tab is gone: companions live as child rows in the list with the full artifact view - commit rows in History and repository detail open the commit in the default browser on click (https sources) - repository detail shows recent jj changes beside the git log, with change ids and bookmarks --- src/tui/app.rs | 216 ++++++++++++++++++++++------------ src/tui/components/preview.rs | 2 +- src/tui/event.rs | 17 ++- src/tui/mod.rs | 1 - src/tui/tests.rs | 15 ++- 5 files changed, 157 insertions(+), 94 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 2ac7bc2..822dbad 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -37,7 +37,7 @@ 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; @@ -64,19 +64,12 @@ 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"), + ("j/k", "move between sections"), ("t", "settings"), ("h", "hooks"), ("c", "config"), ("m", "schemas"), + ("/", "search"), ], ), ( @@ -181,36 +174,19 @@ impl Section { } /// The key that reaches this section from the Sections column, shown as - /// the row prefix so every advertised shortcut actually works. + /// the row prefix; sections without one show a plain label. fn shortcut_label(self) -> &'static str { match self { - Self::Overview => "1", - Self::Skills => "2", - Self::Agents => "3", - Self::Rules => "4", - Self::Repositories => "5", - Self::Adrs => "6", - Self::Provenance => "7", - Self::Variants => "8", - Self::Search => "9", 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), 't' | 'T' => Some(Self::Settings), 'h' | 'H' => Some(Self::Hooks), 'c' | 'C' => Some(Self::Config), @@ -228,7 +204,6 @@ pub enum DetailTab { Provenance = 3, Frontmatter = 4, History = 5, - Companions = 6, } impl DetailTab { @@ -239,7 +214,6 @@ impl DetailTab { Self::Provenance, Self::Frontmatter, Self::History, - Self::Companions, ]; pub(super) fn label(self) -> &'static str { @@ -250,7 +224,6 @@ impl DetailTab { Self::Provenance => "Provenance", Self::Frontmatter => "Frontmatter", Self::History => "History", - Self::Companions => "Companions", } } @@ -423,6 +396,8 @@ struct MouseRegions { 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, @@ -440,6 +415,8 @@ struct DetailCache { 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)] @@ -777,6 +754,7 @@ impl App { 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]); @@ -1048,6 +1026,7 @@ impl App { .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); if self.detail_tab == DetailTab::Code { @@ -1108,6 +1087,7 @@ impl App { frame.render_widget(Paragraph::new("repository not found"), area); return; }; + self.mouse_regions.detail_body = area; let cache_width = area.width.max(1); let key = format!("Module:{name}"); let needs_build = self @@ -1131,6 +1111,7 @@ impl App { } 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() @@ -1146,6 +1127,7 @@ impl App { } } 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, @@ -1153,6 +1135,7 @@ impl App { windowed: true, hunks: Vec::new(), line_map: Vec::new(), + links: row_links, }); } self.render_cached_detail(frame, area); @@ -1174,6 +1157,7 @@ impl App { .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); @@ -1219,6 +1203,18 @@ impl App { } 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, @@ -1226,6 +1222,7 @@ impl App { windowed, hunks, line_map, + links: row_links, }); } self.render_cached_detail(frame, chunks[1]); @@ -1451,6 +1448,12 @@ impl App { } 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, @@ -1459,6 +1462,7 @@ impl App { windowed, hunks, line_map, + links: row_links, }); #[cfg(test)] { @@ -1506,10 +1510,6 @@ impl App { expand_gutter_wrapped(history_lines(artifact), 2, usize::from(width)), true, ), - DetailTab::Companions => ( - expand_gutter_wrapped(companion_lines(artifact), 2, usize::from(width)), - true, - ), } } @@ -1954,6 +1954,21 @@ impl App { } } 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}") + }); + } + } } } @@ -2305,13 +2320,6 @@ impl App { } } - /// Digits address detail tabs while the detail pane has focus; sections - /// otherwise. - #[must_use] - pub fn detail_digits_active(&self) -> bool { - self.focused == ColumnFocus::Detail - } - pub fn set_section_by_shortcut(&mut self, character: char) -> bool { let Some(section) = Section::from_shortcut(character) else { return false; @@ -2633,7 +2641,7 @@ impl App { KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { self.detail_step(-half); } - KeyCode::Char(digit @ '1'..='7') => { + KeyCode::Char(digit @ '1'..='6') => { let index = usize::from(digit as u8 - b'1'); self.set_detail_tab(DetailTab::ALL[index]); } @@ -2654,7 +2662,6 @@ impl App { 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::Char('n') => self.set_detail_tab(DetailTab::Companions), KeyCode::Tab => self.next_detail_tab(), KeyCode::Char('m') => { if !matches!(self.detail_tab, DetailTab::Code | DetailTab::Diff) { @@ -3348,7 +3355,7 @@ fn module_header_lines(module: &ModuleView) -> Vec> { if !module.git_log.is_empty() { lines.push(Line::from("")); lines.push(Line::from(Span::styled( - "Recent commits", + "Recent commits (git)", Style::default().add_modifier(Modifier::BOLD), ))); for commit in &module.git_log { @@ -3747,7 +3754,7 @@ fn render_launch_picker(frame: &mut Frame<'_>, area: Rect, picker: &LaunchPicker fn hint_row(focused: ColumnFocus) -> String { if focused == ColumnFocus::Detail { return [ - "1-7/Tab tabs", + "1-6/Tab tabs", "j/k move", "m comment", "[ ] hunks", @@ -4112,6 +4119,94 @@ fn parse_gutter_new_line(gutter: &str) -> Option { 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() +} + +/// 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( + "Recent changes (jj)", + Style::default().add_modifier(Modifier::BOLD), + )), + ]; + 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> { @@ -4249,33 +4344,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 b4fa775..4b061c9 100644 --- a/src/tui/components/preview.rs +++ b/src/tui/components/preview.rs @@ -117,7 +117,7 @@ impl ArtifactPreview { let block = Block::default() .title(title) .title_bottom(Line::from(Span::styled( - " 1-7 tabs · 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) diff --git a/src/tui/event.rs b/src/tui/event.rs index 98fc7af..3c58393 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -55,13 +55,12 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { app.search_input_key(key); return; } - // Digits switch sections while browsing; with the detail pane focused - // they address its numbered tabs instead. Letter shortcuts (t/h/c/m) - // stay scoped to the Sections column where they are advertised. - if !app.detail_digits_active() - && let KeyCode::Char(character @ '0'..='9') = key.code - && app.set_section_by_shortcut(character) - { + // 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() @@ -97,7 +96,6 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { 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('n') => app.set_detail_tab(super::app::DetailTab::Companions), KeyCode::Char('o') => app.open_repo_tool(false), KeyCode::Char('O') => app.open_repo_tool(true), KeyCode::Char('D') => app.open_deploy_picker(), @@ -115,7 +113,7 @@ 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'..='7') => { + KeyCode::Char(digit @ '1'..='6') => { let index = usize::from(digit as u8 - b'1'); app.set_detail_tab(super::app::DetailTab::ALL[index]); } @@ -125,7 +123,6 @@ fn handle_preview_key(app: &mut App, key: KeyEvent) { 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('n') => app.set_detail_tab(super::app::DetailTab::Companions), _ => {} } } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 520f564..de2ef6b 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -100,7 +100,6 @@ fn detail_tab_from_name(name: &str) -> Option { "provenance" => Some(DetailTab::Provenance), "frontmatter" => Some(DetailTab::Frontmatter), "history" => Some(DetailTab::History), - "companions" => Some(DetailTab::Companions), _ => None, } } diff --git a/src/tui/tests.rs b/src/tui/tests.rs index 6f15cb5..b5525a5 100644 --- a/src/tui/tests.rs +++ b/src/tui/tests.rs @@ -363,17 +363,16 @@ fn search_input_mode_is_explicit() { } #[test] -fn digits_follow_focus_sections_from_list_tabs_from_detail() { +fn digits_switch_detail_tabs_from_any_focus() { let mut app = fixture_app(); - app.focus_next(); - event::handle_key(&mut app, key(KeyCode::Char('2'))); - assert_eq!(app.section(), Section::Skills); - - app.focus_next(); event::handle_key(&mut app, key(KeyCode::Char('3'))); assert_eq!(app.detail_tab(), DetailTab::Diff); - assert_eq!(app.section(), Section::Skills); + 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); } #[test] @@ -451,7 +450,7 @@ fn tuicr_digest_exports_line_comments() { fn mouse_click_selects_section_and_focuses() { let mut app = fixture_app(); let output = rendered(&mut app); - let (x, y) = buffer_position(&output, "2 Skills"); + let (x, y) = buffer_position(&output, "Skills"); app.mouse_click(x, y); From 55a429d530423b2bde4cf14639a941b3f3ea3a31 Mon Sep 17 00:00:00 2001 From: Martin Zeman Date: Sun, 12 Jul 2026 17:34:16 +0200 Subject: [PATCH 21/22] =?UTF-8?q?feat:=20make=20it=20actionable=20?= =?UTF-8?q?=E2=80=94=20in-panel=20filter,=20enterable=20Overview,=20column?= =?UTF-8?q?s,=20positions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - / filters the focused list in place (Esc restores, Enter keeps); ! shows problems only; the list title names the active mode; the Search section keeps its own query input - the Overview is a launcher: Needs-attention counts open the Search view pre-filtered, inventory kinds open their section, and modules open the section with the filter set to that module - the owning module (or variant qualifier) is a dim right-aligned column on every row, truncating from the left; Variants disambiguate inline and their detail shows the actual variant body - empty states name the next key (history, gitui, deploy) and empty module fields are omitted; hints are purposeful per focused pane; list titles carry position x/y - every section has a letter shortcut, shown as its row prefix --- src/tui/app.rs | 294 +++++++++++++++++++++++++++++++++++++++++------ src/tui/event.rs | 15 ++- src/tui/tests.rs | 99 ++++++++++++++-- 3 files changed, 357 insertions(+), 51 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 822dbad..29c6b54 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -65,11 +65,9 @@ pub const KEYBINDINGS: &[(&str, &[(&str, &str)])] = &[ "Sections", &[ ("j/k", "move between sections"), - ("t", "settings"), - ("h", "hooks"), - ("c", "config"), - ("m", "schemas"), - ("/", "search"), + ("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"), ], ), ( @@ -177,16 +175,33 @@ impl Section { /// 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 { + '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), @@ -269,6 +284,16 @@ enum ListTarget { 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, @@ -468,6 +493,7 @@ struct CommentPrompt { text: String, } +#[allow(clippy::struct_excessive_bools)] pub struct App { root: PathBuf, providers: Vec<(String, String)>, @@ -527,6 +553,12 @@ pub struct App { /// 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 { @@ -601,6 +633,9 @@ impl App { 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, @@ -847,7 +882,31 @@ impl App { } fn render_list(&mut self, frame: &mut Frame<'_>, area: Rect) { - let title = format!(" {} ", self.section.label()); + 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); @@ -903,13 +962,26 @@ impl App { Span::raw(" "), Span::styled(row.label.clone(), base), ]; - // Detail text only on the selected row: unselected rows - // stay calm and nothing truncates while browsing. - if index == selected && !row.detail.is_empty() { - spans.push(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 + row.label.chars().count(); + let room = usize::from(inner.width).saturating_sub(used); + if room >= 4 { + let detail_width = row.detail.chars().count(); + let (text, shown_width) = if detail_width < room { + (row.detail.clone(), detail_width) + } else { + let take = room.saturating_sub(2); + let tail: String = + row.detail.chars().skip(detail_width - take).collect(); + (format!("…{tail}"), take + 1) + }; + 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)) }) @@ -1643,7 +1715,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() @@ -1652,9 +1724,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 }), @@ -1882,9 +1964,33 @@ impl App { match self.focused { ColumnFocus::Sections => self.focused = ColumnFocus::List, ColumnFocus::List => { - if let Some(ListTarget::OverviewMode) = self.selected_target() { - self.toggle_overview_mode(); - return; + 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 }) => { + if let Some(section) = Section::from_name(&kind) { + self.set_section(section); + self.list_filter = module; + self.invalidate_rows(); + self.clamp_list_selection(); + } + return; + } + _ => {} } if let Some(ListTarget::ProvenanceArtifact { .. }) = self.selected_target() { self.detail_tab = DetailTab::Provenance; @@ -2682,13 +2788,28 @@ impl App { 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)] @@ -2698,6 +2819,54 @@ 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.invalidate_rows(); + } + KeyCode::Char(character) => { + self.list_filter.push(character); + self.list_selected[self.section as usize] = 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.invalidate_rows(); + self.clamp_list_selection(); + } + fn invalidate_rows(&mut self) { self.rows_dirty = true; } @@ -2784,7 +2953,8 @@ 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 { @@ -2796,7 +2966,43 @@ impl App { 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 { @@ -2914,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(), @@ -3333,13 +3539,17 @@ fn module_header_lines(module: &ModuleView) -> Vec> { 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" } )), ]; + 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()))); } @@ -3763,13 +3973,19 @@ fn hint_row(focused: ColumnFocus) -> String { ] .join(" · "); } - KEYBINDINGS - .iter() - .flat_map(|(_, bindings)| bindings.iter()) - .take(8) - .map(|(key, description)| format!("{key} {description}")) - .collect::>() - .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(" · "), + } } /// Greedy word-wrap for plain header text, needed because the preview @@ -4216,7 +4432,7 @@ fn deployment_lines(groups: &[commands::view::DeployGroup]) -> Vec ))]; if groups.is_empty() { lines.push(Line::from(Span::styled( - "not deployed anywhere", + "not deployed anywhere — D deploys it to a target", Style::default().fg(Color::DarkGray), ))); } @@ -4316,7 +4532,13 @@ fn frontmatter_lines(artifact: &ArtifactView, width: u16) -> Vec> 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 { diff --git a/src/tui/event.rs b/src/tui/event.rs index 3c58393..bc85565 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -55,6 +55,15 @@ 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. @@ -78,10 +87,8 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { 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); - app.begin_search_input(); - } + 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(), diff --git a/src/tui/tests.rs b/src/tui/tests.rs index b5525a5..d5130bc 100644 --- a/src/tui/tests.rs +++ b/src/tui/tests.rs @@ -285,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] @@ -349,6 +348,7 @@ fn unknown_palette_command_sets_error() { #[test] fn search_input_mode_is_explicit() { let mut app = fixture_app(); + app.set_section_by_number(9); event::handle_key(&mut app, key(KeyCode::Char('/'))); for character in ['h', 'e', 'l', 'l', 'o'] { @@ -530,6 +530,83 @@ fn launch_queues_harness_in_module_repo() { 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::Skills); + let filtered = rendered(&mut app); + assert!(filtered.contains("/forge-core")); + 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(); From de35975266e724a8aa0857bbae45444b21deb699 Mon Sep 17 00:00:00 2001 From: Martin Zeman Date: Sun, 12 Jul 2026 17:43:12 +0200 Subject: [PATCH 22/22] =?UTF-8?q?fix:=20actionable-round=20adversarial=20p?= =?UTF-8?q?ass=20=E2=80=94=20exact=20module=20jumps,=20honest=20help?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Overview module rows jump via the Search module filter instead of a substring text filter, so similarly named modules and companion rows scope correctly - filter edits and the problems toggle reset the list viewport with the selection, keeping the highlight on screen - the right-aligned detail column measures display width (wide and combining characters) and truncates on real column boundaries - help matches dispatch: / filters the focused list, the dead n binding is gone, and tab letters note their Sections-focus exception --- src/tui/app.rs | 46 ++++++++++++++++++++++++++++++++-------------- src/tui/tests.rs | 4 ++-- 2 files changed, 34 insertions(+), 16 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 29c6b54..21a81a8 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -15,6 +15,7 @@ use ratatui::{ text::{Line, Span, Text}, widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap}, }; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use commands::{ services::{ @@ -73,12 +74,13 @@ pub const KEYBINDINGS: &[(&str, &[(&str, &str)])] = &[ ( "Actions", &[ - ("/", "search"), + ("/", "filter the focused list (Search: edit query)"), (":", "palette"), ("r", "refresh"), ("y", "copy install snippet or path"), ("Tab", "next detail tab"), - ("p c d v f i n", "detail tabs"), + ("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"), @@ -966,17 +968,14 @@ impl App { // right-aligned column on every row; when tight it // truncates from the left, never the label. if !row.detail.is_empty() { - let used = 2 + row.label.chars().count(); + let used = 2 + UnicodeWidthStr::width(row.label.as_str()); let room = usize::from(inner.width).saturating_sub(used); if room >= 4 { - let detail_width = row.detail.chars().count(); + let detail_width = UnicodeWidthStr::width(row.detail.as_str()); let (text, shown_width) = if detail_width < room { (row.detail.clone(), detail_width) } else { - let take = room.saturating_sub(2); - let tail: String = - row.detail.chars().skip(detail_width - take).collect(); - (format!("…{tail}"), take + 1) + truncate_left_to_width(&row.detail, room.saturating_sub(2)) }; let pad = room.saturating_sub(shown_width); spans.push(Span::raw(" ".repeat(pad))); @@ -1982,12 +1981,10 @@ impl App { return; } Some(ListTarget::ModuleJump { kind, module }) => { - if let Some(section) = Section::from_name(&kind) { - self.set_section(section); - self.list_filter = module; - self.invalidate_rows(); - self.clamp_list_selection(); - } + self.search = builders::SearchFilters::empty(); + self.search.kind = kind; + self.search.module = module; + self.set_section(Section::Search); return; } _ => {} @@ -2832,11 +2829,13 @@ impl App { 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(); } _ => {} @@ -2863,6 +2862,7 @@ impl App { 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(); } @@ -3988,6 +3988,24 @@ fn hint_row(focused: ColumnFocus) -> String { } } +/// 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) +} + /// 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. diff --git a/src/tui/tests.rs b/src/tui/tests.rs index d5130bc..ada040d 100644 --- a/src/tui/tests.rs +++ b/src/tui/tests.rs @@ -576,9 +576,9 @@ fn overview_inventory_rows_jump_to_sections() { app.set_section_by_number(1); app.move_list_selection(1); event::handle_key(&mut app, key(KeyCode::Enter)); - assert_eq!(app.section(), Section::Skills); + assert_eq!(app.section(), Section::Search); let filtered = rendered(&mut app); - assert!(filtered.contains("/forge-core")); + assert!(filtered.contains("module: forge-core") || filtered.contains("BuildSkill")); assert!(filtered.contains("BuildSkill")); }