From 5070b2d785bc7b7136a221399e1218afd9876b17 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 13:45:32 -0700 Subject: [PATCH] feat: add ecc2 worktree file previews --- ecc2/src/tui/dashboard.rs | 47 ++++++++---- ecc2/src/worktree/mod.rs | 153 +++++++++++++++++++++++++++++++++++++- 2 files changed, 185 insertions(+), 15 deletions(-) diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index c2a2eaf2..9257528b 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -7,12 +7,12 @@ use ratatui::{ use std::collections::HashMap; use tokio::sync::broadcast; -use super::widgets::{budget_state, format_currency, format_token_count, BudgetState, TokenMeter}; +use super::widgets::{BudgetState, TokenMeter, budget_state, format_currency, format_token_count}; use crate::comms; use crate::config::{Config, PaneLayout}; use crate::observability::ToolLogEntry; use crate::session::manager; -use crate::session::output::{OutputEvent, OutputLine, SessionOutputStore, OUTPUT_BUFFER_LIMIT}; +use crate::session::output::{OUTPUT_BUFFER_LIMIT, OutputEvent, OutputLine, SessionOutputStore}; use crate::session::store::{DaemonActivity, StateStore}; use crate::session::{Session, SessionMessage, SessionState}; use crate::worktree; @@ -31,6 +31,7 @@ const MIN_PANE_SIZE_PERCENT: u16 = 20; const MAX_PANE_SIZE_PERCENT: u16 = 80; const PANE_RESIZE_STEP_PERCENT: u16 = 5; const MAX_LOG_ENTRIES: u64 = 12; +const MAX_DIFF_PREVIEW_LINES: usize = 6; pub struct Dashboard { db: StateStore, @@ -51,6 +52,7 @@ pub struct Dashboard { selected_route_preview: Option, logs: Vec, selected_diff_summary: Option, + selected_diff_preview: Vec, selected_pane: Pane, selected_session: usize, show_help: bool, @@ -157,6 +159,7 @@ impl Dashboard { selected_route_preview: None, logs: Vec::new(), selected_diff_summary: None, + selected_diff_preview: Vec::new(), selected_pane: Pane::Sessions, selected_session: 0, show_help: false, @@ -1257,11 +1260,16 @@ impl Dashboard { } fn sync_selected_diff(&mut self) { - self.selected_diff_summary = self + let worktree = self .sessions .get(self.selected_session) - .and_then(|session| session.worktree.as_ref()) - .and_then(|worktree| worktree::diff_summary(worktree).ok().flatten()); + .and_then(|session| session.worktree.as_ref()); + + self.selected_diff_summary = + worktree.and_then(|worktree| worktree::diff_summary(worktree).ok().flatten()); + self.selected_diff_preview = worktree + .and_then(|worktree| worktree::diff_file_preview(worktree, MAX_DIFF_PREVIEW_LINES).ok()) + .unwrap_or_default(); } fn sync_selected_messages(&mut self) { @@ -1653,6 +1661,12 @@ impl Dashboard { if let Some(diff_summary) = self.selected_diff_summary.as_ref() { lines.push(format!("Diff {diff_summary}")); } + if !self.selected_diff_preview.is_empty() { + lines.push("Changed files".to_string()); + for entry in &self.selected_diff_preview { + lines.push(format!("- {entry}")); + } + } } lines.push(format!( @@ -1914,11 +1928,7 @@ impl Dashboard { fn log_field<'a>(&self, value: &'a str) -> &'a str { let trimmed = value.trim(); - if trimmed.is_empty() { - "n/a" - } else { - trimmed - } + if trimmed.is_empty() { "n/a" } else { trimmed } } fn short_timestamp(&self, timestamp: &str) -> String { @@ -2140,7 +2150,7 @@ fn format_duration(duration_secs: u64) -> String { mod tests { use anyhow::Result; use chrono::Utc; - use ratatui::{backend::TestBackend, Terminal}; + use ratatui::{Terminal, backend::TestBackend}; use std::path::PathBuf; use uuid::Uuid; @@ -2212,11 +2222,18 @@ mod tests { }], ); dashboard.selected_diff_summary = Some("1 file changed, 2 insertions(+)".to_string()); + dashboard.selected_diff_preview = vec![ + "Branch M src/main.rs".to_string(), + "Working ?? notes.txt".to_string(), + ]; let text = dashboard.selected_session_metrics_text(); assert!(text.contains("Branch ecc/focus | Base main")); assert!(text.contains("Worktree /tmp/ecc/focus")); assert!(text.contains("Diff 1 file changed, 2 insertions(+)")); + assert!(text.contains("Changed files")); + assert!(text.contains("- Branch M src/main.rs")); + assert!(text.contains("- Working ?? notes.txt")); assert!(text.contains("Last output last useful output")); assert!(text.contains("Needs attention:")); assert!(text.contains("Failed failed-8 | Render dashboard rows")); @@ -2356,7 +2373,8 @@ mod tests { } #[test] - fn selected_session_metrics_text_recommends_operator_escalation_when_chronic_saturation_is_stuck() { + fn selected_session_metrics_text_recommends_operator_escalation_when_chronic_saturation_is_stuck() + { let mut dashboard = test_dashboard( vec![sample_session( "focus-12345678", @@ -2383,7 +2401,9 @@ mod tests { }; let text = dashboard.selected_session_metrics_text(); - assert!(text.contains("Operator escalation recommended: chronic saturation is not clearing")); + assert!( + text.contains("Operator escalation recommended: chronic saturation is not clearing") + ); } #[test] @@ -3051,6 +3071,7 @@ mod tests { selected_route_preview: None, logs: Vec::new(), selected_diff_summary: None, + selected_diff_preview: Vec::new(), selected_pane: Pane::Sessions, selected_session, show_help: false, diff --git a/ecc2/src/worktree/mod.rs b/ecc2/src/worktree/mod.rs index 61896666..5b77b07b 100644 --- a/ecc2/src/worktree/mod.rs +++ b/ecc2/src/worktree/mod.rs @@ -107,6 +107,35 @@ pub fn diff_summary(worktree: &WorktreeInfo) -> Result> { } } +pub fn diff_file_preview(worktree: &WorktreeInfo, limit: usize) -> Result> { + let mut preview = Vec::new(); + let base_ref = format!("{}...HEAD", worktree.base_branch); + + let committed = git_diff_name_status(&worktree.path, &[&base_ref])?; + if !committed.is_empty() { + preview.extend( + committed + .into_iter() + .map(|entry| format!("Branch {entry}")) + .take(limit.saturating_sub(preview.len())), + ); + } + + if preview.len() < limit { + let working = git_status_short(&worktree.path)?; + if !working.is_empty() { + preview.extend( + working + .into_iter() + .map(|entry| format!("Working {entry}")) + .take(limit.saturating_sub(preview.len())), + ); + } + } + + Ok(preview) +} + fn git_diff_shortstat(worktree_path: &Path, extra_args: &[&str]) -> Result> { let mut command = Command::new("git"); command @@ -137,6 +166,60 @@ fn git_diff_shortstat(worktree_path: &Path, extra_args: &[&str]) -> Result Result> { + let mut command = Command::new("git"); + command + .arg("-C") + .arg(worktree_path) + .arg("diff") + .arg("--name-status"); + command.args(extra_args); + + let output = command + .output() + .context("Failed to generate worktree diff file preview")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::warn!( + "Worktree diff file preview warning for {}: {stderr}", + worktree_path.display() + ); + return Ok(Vec::new()); + } + + Ok(parse_nonempty_lines(&output.stdout)) +} + +fn git_status_short(worktree_path: &Path) -> Result> { + let output = Command::new("git") + .arg("-C") + .arg(worktree_path) + .args(["status", "--short"]) + .output() + .context("Failed to generate worktree status preview")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::warn!( + "Worktree status preview warning for {}: {stderr}", + worktree_path.display() + ); + return Ok(Vec::new()); + } + + Ok(parse_nonempty_lines(&output.stdout)) +} + +fn parse_nonempty_lines(stdout: &[u8]) -> Vec { + String::from_utf8_lossy(stdout) + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(ToOwned::to_owned) + .collect() +} + fn get_current_branch(repo_root: &Path) -> Result { let output = Command::new("git") .arg("-C") @@ -157,7 +240,11 @@ mod tests { use uuid::Uuid; fn run_git(repo: &Path, args: &[&str]) -> Result<()> { - let output = Command::new("git").arg("-C").arg(repo).args(args).output()?; + let output = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .output()?; if !output.status.success() { anyhow::bail!("{}", String::from_utf8_lossy(&output.stderr)); } @@ -196,7 +283,10 @@ mod tests { base_branch: "main".to_string(), }; - assert_eq!(diff_summary(&info)?, Some("Clean relative to main".to_string())); + assert_eq!( + diff_summary(&info)?, + Some("Clean relative to main".to_string()) + ); fs::write(worktree_dir.join("README.md"), "hello\nmore\n")?; let dirty = diff_summary(&info)?.expect("dirty summary"); @@ -212,4 +302,63 @@ mod tests { let _ = fs::remove_dir_all(root); Ok(()) } + + #[test] + fn diff_file_preview_reports_branch_and_working_tree_files() -> Result<()> { + let root = std::env::temp_dir().join(format!("ecc2-worktree-preview-{}", Uuid::new_v4())); + let repo = root.join("repo"); + fs::create_dir_all(&repo)?; + + run_git(&repo, &["init", "-b", "main"])?; + run_git(&repo, &["config", "user.email", "ecc@example.com"])?; + run_git(&repo, &["config", "user.name", "ECC"])?; + fs::write(repo.join("README.md"), "hello\n")?; + run_git(&repo, &["add", "README.md"])?; + run_git(&repo, &["commit", "-m", "init"])?; + + let worktree_dir = root.join("wt-1"); + run_git( + &repo, + &[ + "worktree", + "add", + "-b", + "ecc/test", + worktree_dir.to_str().expect("utf8 path"), + "HEAD", + ], + )?; + + fs::write(worktree_dir.join("src.txt"), "branch\n")?; + run_git(&worktree_dir, &["add", "src.txt"])?; + run_git(&worktree_dir, &["commit", "-m", "branch file"])?; + fs::write(worktree_dir.join("README.md"), "hello\nworking\n")?; + + let info = WorktreeInfo { + path: worktree_dir.clone(), + branch: "ecc/test".to_string(), + base_branch: "main".to_string(), + }; + + let preview = diff_file_preview(&info, 6)?; + assert!( + preview + .iter() + .any(|line| line.contains("Branch A") && line.contains("src.txt")) + ); + assert!( + preview + .iter() + .any(|line| line.contains("Working M") && line.contains("README.md")) + ); + + let _ = Command::new("git") + .arg("-C") + .arg(&repo) + .args(["worktree", "remove", "--force"]) + .arg(&worktree_dir) + .output(); + let _ = fs::remove_dir_all(root); + Ok(()) + } }