From 87d520f0b1290f398ea9fee319801f209a4da552 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 13:49:35 -0700 Subject: [PATCH] feat: add ecc2 diff viewer mode --- ecc2/src/tui/app.rs | 1 + ecc2/src/tui/dashboard.rs | 128 +++++++++++++++++++++++++++++++++----- ecc2/src/worktree/mod.rs | 113 +++++++++++++++++++++++++++++++++ 3 files changed, 228 insertions(+), 14 deletions(-) diff --git a/ecc2/src/tui/app.rs b/ecc2/src/tui/app.rs index c9c90fc9..7495bd89 100644 --- a/ecc2/src/tui/app.rs +++ b/ecc2/src/tui/app.rs @@ -45,6 +45,7 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { (_, KeyCode::Char('i')) => dashboard.drain_inbox_selected().await, (_, KeyCode::Char('g')) => dashboard.auto_dispatch_backlog().await, (_, KeyCode::Char('G')) => dashboard.coordinate_backlog().await, + (_, KeyCode::Char('v')) => dashboard.toggle_output_mode(), (_, KeyCode::Char('p')) => dashboard.toggle_auto_dispatch_policy(), (_, KeyCode::Char(',')) => dashboard.adjust_auto_dispatch_limit(-1), (_, KeyCode::Char('.')) => dashboard.adjust_auto_dispatch_limit(1), diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 9257528b..05540ae2 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -32,6 +32,7 @@ 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; +const MAX_DIFF_PATCH_LINES: usize = 80; pub struct Dashboard { db: StateStore, @@ -53,6 +54,8 @@ pub struct Dashboard { logs: Vec, selected_diff_summary: Option, selected_diff_preview: Vec, + selected_diff_patch: Option, + output_mode: OutputMode, selected_pane: Pane, selected_session: usize, show_help: bool, @@ -85,6 +88,12 @@ enum Pane { Log, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OutputMode { + SessionOutput, + WorktreeDiff, +} + #[derive(Debug, Clone, Copy)] struct PaneAreas { sessions: Rect, @@ -160,6 +169,8 @@ impl Dashboard { logs: Vec::new(), selected_diff_summary: None, selected_diff_preview: Vec::new(), + selected_diff_patch: None, + output_mode: OutputMode::SessionOutput, selected_pane: Pane::Sessions, selected_session: 0, show_help: false, @@ -319,27 +330,43 @@ impl Dashboard { fn render_output(&mut self, frame: &mut Frame, area: Rect) { self.sync_output_scroll(area.height.saturating_sub(2) as usize); - let content = if self.sessions.get(self.selected_session).is_some() { - let lines = self.selected_output_lines(); - - if lines.is_empty() { - "Waiting for session output...".to_string() - } else { - lines - .iter() - .map(|line| line.text.as_str()) - .collect::>() - .join("\n") + let (title, content) = if self.sessions.get(self.selected_session).is_some() { + match self.output_mode { + OutputMode::SessionOutput => { + let lines = self.selected_output_lines(); + let content = if lines.is_empty() { + "Waiting for session output...".to_string() + } else { + lines.iter().map(|line| line.text.as_str()).collect::>().join("\n") + }; + (" Output ", content) + } + OutputMode::WorktreeDiff => { + let content = self + .selected_diff_patch + .clone() + .or_else(|| { + self.selected_diff_summary.as_ref().map(|summary| { + format!( + "{summary}\n\nNo patch content to preview yet. The worktree may be clean or only have summary-level changes." + ) + }) + }) + .unwrap_or_else(|| { + "No worktree diff available for the selected session.".to_string() + }); + (" Diff ", content) + } } } else { - "No sessions. Press 'n' to start one.".to_string() + (" Output ", "No sessions. Press 'n' to start one.".to_string()) }; let paragraph = Paragraph::new(content) .block( Block::default() .borders(Borders::ALL) - .title(" Output ") + .title(title) .border_style(self.pane_border_style(Pane::Output)), ) .scroll((self.output_scroll_offset as u16, 0)); @@ -427,7 +454,7 @@ impl Dashboard { fn render_status_bar(&self, frame: &mut Frame, area: Rect) { let text = format!( - " [n]ew session [a]ssign re[b]alance global re[B]alance dra[i]n inbox [g]lobal dispatch coordinate [G]lobal toggle [p]olicy [,/.] dispatch limit [s]top [u]resume [x]cleanup [d]elete [r]efresh [Tab] switch pane [j/k] scroll [+/-] resize [{}] layout [?] help [q]uit ", + " [n]ew session [a]ssign re[b]alance global re[B]alance dra[i]n inbox [g]lobal dispatch coordinate [G]lobal [v]iew diff toggle [p]olicy [,/.] dispatch limit [s]top [u]resume [x]cleanup [d]elete [r]efresh [Tab] switch pane [j/k] scroll [+/-] resize [{}] layout [?] help [q]uit ", self.layout_label() ); let text = if let Some(note) = self.operator_note.as_ref() { @@ -478,6 +505,7 @@ impl Dashboard { " i Drain unread task handoffs from selected lead", " g Auto-dispatch unread handoffs across lead sessions", " G Dispatch then rebalance backlog across lead teams", + " v Toggle selected worktree diff in output pane", " p Toggle daemon auto-dispatch policy and persist config", " ,/. Decrease/increase auto-dispatch limit per lead", " s Stop selected session", @@ -669,6 +697,27 @@ impl Dashboard { self.refresh_logs(); } + pub fn toggle_output_mode(&mut self) { + match self.output_mode { + OutputMode::SessionOutput => { + if self.selected_diff_patch.is_some() || self.selected_diff_summary.is_some() { + self.output_mode = OutputMode::WorktreeDiff; + self.selected_pane = Pane::Output; + self.output_follow = false; + self.output_scroll_offset = 0; + self.set_operator_note("showing selected worktree diff".to_string()); + } else { + self.set_operator_note("no worktree diff for selected session".to_string()); + } + } + OutputMode::WorktreeDiff => { + self.output_mode = OutputMode::SessionOutput; + self.reset_output_view(); + self.set_operator_note("showing session output".to_string()); + } + } + } + pub async fn assign_selected(&mut self) { let Some(source_session) = self.sessions.get(self.selected_session) else { return; @@ -1270,6 +1319,11 @@ impl Dashboard { self.selected_diff_preview = worktree .and_then(|worktree| worktree::diff_file_preview(worktree, MAX_DIFF_PREVIEW_LINES).ok()) .unwrap_or_default(); + self.selected_diff_patch = worktree + .and_then(|worktree| worktree::diff_patch_preview(worktree, MAX_DIFF_PATCH_LINES).ok().flatten()); + if self.output_mode == OutputMode::WorktreeDiff && self.selected_diff_patch.is_none() { + self.output_mode = OutputMode::SessionOutput; + } } fn sync_selected_messages(&mut self) { @@ -1950,6 +2004,20 @@ impl Dashboard { .collect::>() .join("\n") } + + #[cfg(test)] + fn rendered_output_text(&mut self, width: u16, height: u16) -> String { + let backend = ratatui::backend::TestBackend::new(width, height); + let mut terminal = ratatui::Terminal::new(backend).expect("terminal"); + terminal.draw(|frame| self.render(frame)).expect("draw"); + terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect::() + } } impl Pane { @@ -2239,6 +2307,36 @@ mod tests { assert!(text.contains("Failed failed-8 | Render dashboard rows")); } + #[test] + fn toggle_output_mode_switches_to_worktree_diff_preview() { + let mut dashboard = test_dashboard( + vec![sample_session( + "focus-12345678", + "planner", + SessionState::Running, + Some("ecc/focus"), + 512, + 42, + )], + 0, + ); + dashboard.selected_diff_summary = Some("1 file changed".to_string()); + dashboard.selected_diff_patch = Some( + "--- Branch diff vs main ---\ndiff --git a/src/lib.rs b/src/lib.rs\n+hello".to_string(), + ); + + dashboard.toggle_output_mode(); + + assert_eq!(dashboard.output_mode, OutputMode::WorktreeDiff); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("showing selected worktree diff") + ); + let rendered = dashboard.rendered_output_text(180, 30); + assert!(rendered.contains("Diff")); + assert!(rendered.contains("diff --git a/src/lib.rs b/src/lib.rs")); + } + #[test] fn selected_session_metrics_text_includes_team_capacity_summary() { let mut dashboard = test_dashboard( @@ -3072,6 +3170,8 @@ mod tests { logs: Vec::new(), selected_diff_summary: None, selected_diff_preview: Vec::new(), + selected_diff_patch: None, + output_mode: OutputMode::SessionOutput, selected_pane: Pane::Sessions, selected_session, show_help: false, diff --git a/ecc2/src/worktree/mod.rs b/ecc2/src/worktree/mod.rs index 5b77b07b..de7edf4a 100644 --- a/ecc2/src/worktree/mod.rs +++ b/ecc2/src/worktree/mod.rs @@ -136,6 +136,34 @@ pub fn diff_file_preview(worktree: &WorktreeInfo, limit: usize) -> Result Result> { + let mut remaining = max_lines.max(1); + let mut sections = Vec::new(); + let base_ref = format!("{}...HEAD", worktree.base_branch); + + let committed = git_diff_patch_lines(&worktree.path, &[&base_ref])?; + if !committed.is_empty() && remaining > 0 { + let taken = take_preview_lines(&committed, &mut remaining); + sections.push(format!( + "--- Branch diff vs {} ---\n{}", + worktree.base_branch, + taken.join("\n") + )); + } + + let working = git_diff_patch_lines(&worktree.path, &[])?; + if !working.is_empty() && remaining > 0 { + let taken = take_preview_lines(&working, &mut remaining); + sections.push(format!("--- Working tree diff ---\n{}", taken.join("\n"))); + } + + if sections.is_empty() { + Ok(None) + } else { + Ok(Some(sections.join("\n\n"))) + } +} + fn git_diff_shortstat(worktree_path: &Path, extra_args: &[&str]) -> Result> { let mut command = Command::new("git"); command @@ -191,6 +219,31 @@ fn git_diff_name_status(worktree_path: &Path, extra_args: &[&str]) -> Result Result> { + let mut command = Command::new("git"); + command + .arg("-C") + .arg(worktree_path) + .arg("diff") + .args(["--stat", "--patch", "--find-renames"]); + command.args(extra_args); + + let output = command + .output() + .context("Failed to generate worktree patch preview")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::warn!( + "Worktree patch 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") @@ -220,6 +273,13 @@ fn parse_nonempty_lines(stdout: &[u8]) -> Vec { .collect() } +fn take_preview_lines(lines: &[String], remaining: &mut usize) -> Vec { + let count = (*remaining).min(lines.len()); + let taken = lines.iter().take(count).cloned().collect::>(); + *remaining = remaining.saturating_sub(count); + taken +} + fn get_current_branch(repo_root: &Path) -> Result { let output = Command::new("git") .arg("-C") @@ -361,4 +421,57 @@ mod tests { let _ = fs::remove_dir_all(root); Ok(()) } + + #[test] + fn diff_patch_preview_reports_branch_and_working_tree_sections() -> Result<()> { + let root = std::env::temp_dir().join(format!("ecc2-worktree-patch-{}", 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_patch_preview(&info, 40)?.expect("patch preview"); + assert!(preview.contains("--- Branch diff vs main ---")); + assert!(preview.contains("--- Working tree diff ---")); + assert!(preview.contains("src.txt")); + assert!(preview.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(()) + } }