From 7d929384f8c411eba0f708577f59dea6b0bb7733 Mon Sep 17 00:00:00 2001 From: martinabeleda Date: Fri, 13 Mar 2026 15:36:12 -0700 Subject: [PATCH 1/2] add help panel with keybindings and symbols, clean up top bar --- README.md | 10 ++++--- src/app.rs | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- src/git.rs | 31 ++++++++++++++++++++- src/ui.rs | 67 ++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 180 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 9741fea..4a5da94 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ ripdiff --path /some/repo | Key | Action | |-----|--------| | `Tab` / `Shift-Tab` | Toggle focus between panels | +| `h` / `?` | Open or close help | | `t` | Toggle between inline and side-by-side diff | | `r` | Force refresh | | `q` / `Esc` | Quit | @@ -61,7 +62,7 @@ ripdiff --path /some/repo |-----|--------| | `j` / `↓` | Move file selection down | | `k` / `↑` | Move file selection up | -| `l` / `→` | Switch to diff panel | +| `→` | Switch to diff panel | | `gg` / `G` | Jump to top / bottom of file list | | `s` / `S` | Toggle selected file staged / toggle all files staged | | `Space e` | Hide / show file list sidebar | @@ -73,7 +74,7 @@ ripdiff --path /some/repo |-----|--------| | `j` / `↓` | Scroll down one line | | `k` / `↑` | Scroll up one line | -| `h` / `←` | Switch to file list | +| `←` | Switch to file list | | `Ctrl-d` / `Ctrl-u` | Scroll half page down / up | | `gg` / `G` | Jump to top / bottom of diff | | `s` / `S` | Toggle selected file staged / toggle all files staged | @@ -96,7 +97,7 @@ Edit a file in another terminal — the diff auto-updates within ~1 second. ## Layout ``` - ripdiff [repo: myproject] 3 files changed mode: inline panel: files + ripdiff [repo: myproject]  main 3 files changed mode: inline panel: files M src/main.rs +5-2 │ src/main.rs A src/lib.rs +3 │ M README.md +1-1 │ fn main() { @@ -105,7 +106,8 @@ Edit a file in another terminal — the diff auto-updates within ~1 second. │ } ``` -- 25% left: file list with status indicators (M/A/D/R/?) and stats +- 25% left: file list with status indicators (M/A/D/R/?) and stage markers (`●` staged, `○` unstaged, `◐` mixed) - 75% right: diff output with scrollbar +- `h` opens a help popup with keybinding and symbol descriptions - Minimal borders — just a vertical divider between panels - Auto-refreshes on `.git/index` changes and every 500ms diff --git a/src/app.rs b/src/app.rs index 367a058..2b2cc73 100644 --- a/src/app.rs +++ b/src/app.rs @@ -35,6 +35,7 @@ pub struct UiState { pub scroll_offset: usize, pub pending_g: bool, pub pending_space: bool, + pub show_help: bool, pub show_sidebar: bool, pub hidden_files: HashSet, pub diff_mode: DiffMode, @@ -62,6 +63,7 @@ impl App { scroll_offset: 0, pending_g: false, pending_space: false, + show_help: false, show_sidebar: true, hidden_files: HashSet::new(), diff_mode: DiffMode::Inline, @@ -326,6 +328,20 @@ impl App { } pub fn handle_key(&mut self, key: KeyEvent) { + if self.ui.show_help { + match (key.code, key.modifiers) { + (KeyCode::Char('q'), KeyModifiers::NONE) | (KeyCode::Esc, _) => { + self.should_quit = true; + } + (KeyCode::Char('h'), KeyModifiers::NONE) + | (KeyCode::Char('?'), KeyModifiers::SHIFT) => { + self.ui.show_help = false; + } + _ => {} + } + return; + } + if self.handle_pending_key_sequence(key) { return; } @@ -352,6 +368,13 @@ impl App { self.toggle_diff_mode(); return; } + (KeyCode::Char('h'), KeyModifiers::NONE) + | (KeyCode::Char('?'), KeyModifiers::SHIFT) => { + self.ui.show_help = true; + self.ui.pending_g = false; + self.ui.pending_space = false; + return; + } (KeyCode::Char('s'), KeyModifiers::NONE) => { self.toggle_selected_file_staged(); return; @@ -420,7 +443,7 @@ impl App { match key.code { KeyCode::Char('j') | KeyCode::Down => self.move_down(), KeyCode::Char('k') | KeyCode::Up => self.move_up(), - KeyCode::Char('l') | KeyCode::Right => { + KeyCode::Right => { self.ui.focus = Panel::Diff; } KeyCode::Char('G') => self.jump_bottom(), @@ -434,7 +457,7 @@ impl App { match (key.code, key.modifiers) { (KeyCode::Char('j'), KeyModifiers::NONE) | (KeyCode::Down, _) => self.scroll_down(1), (KeyCode::Char('k'), KeyModifiers::NONE) | (KeyCode::Up, _) => self.scroll_up(1), - (KeyCode::Char('h'), KeyModifiers::NONE) | (KeyCode::Left, _) => { + (KeyCode::Left, _) => { if self.ui.show_sidebar { self.ui.focus = Panel::Files; } @@ -653,6 +676,7 @@ mod tests { scroll_offset: 0, pending_g: false, pending_space: false, + show_help: false, show_sidebar: true, hidden_files: HashSet::new(), diff_mode: DiffMode::Inline, @@ -688,6 +712,7 @@ mod tests { let mut app = App { repo_root: PathBuf::from("."), snapshot: RepoSnapshot { + branch: None, files: vec![FileStat { path: "src/main.rs".to_string(), additions: 1, @@ -704,6 +729,7 @@ mod tests { scroll_offset: 0, pending_g: false, pending_space: false, + show_help: false, show_sidebar: true, hidden_files: HashSet::new(), diff_mode: DiffMode::Inline, @@ -742,6 +768,7 @@ mod tests { #[test] fn apply_snapshot_keeps_cache_when_snapshot_is_unchanged() { let snapshot = RepoSnapshot { + branch: None, files: vec![FileStat { path: "src/main.rs".to_string(), additions: 1, @@ -766,6 +793,7 @@ mod tests { scroll_offset: 0, pending_g: false, pending_space: false, + show_help: false, show_sidebar: true, hidden_files: HashSet::new(), diff_mode: DiffMode::Inline, @@ -808,6 +836,7 @@ mod tests { let mut app = App { repo_root: PathBuf::from("."), snapshot: RepoSnapshot { + branch: None, files: vec![FileStat { path: "src/main.rs".to_string(), additions: 1, @@ -824,6 +853,7 @@ mod tests { scroll_offset: 0, pending_g: false, pending_space: false, + show_help: false, show_sidebar: true, hidden_files: HashSet::new(), diff_mode: DiffMode::Inline, @@ -870,6 +900,7 @@ mod tests { let mut app = App { repo_root: PathBuf::from("."), snapshot: RepoSnapshot { + branch: None, files: vec![FileStat { path: "src/main.rs".to_string(), additions: 1, @@ -886,6 +917,7 @@ mod tests { scroll_offset: 0, pending_g: false, pending_space: false, + show_help: false, show_sidebar: true, hidden_files: HashSet::new(), diff_mode: DiffMode::Inline, @@ -933,6 +965,7 @@ mod tests { let mut app = App { repo_root: PathBuf::from("."), snapshot: RepoSnapshot { + branch: None, files: vec![ FileStat { path: "a.rs".to_string(), @@ -969,6 +1002,7 @@ mod tests { scroll_offset: 0, pending_g: false, pending_space: false, + show_help: false, show_sidebar: true, hidden_files: HashSet::new(), diff_mode: DiffMode::Inline, @@ -1028,6 +1062,7 @@ mod tests { let mut app = App { repo_root: PathBuf::from("."), snapshot: RepoSnapshot { + branch: None, files: vec![FileStat { path: "src/main.rs".to_string(), additions: 1, @@ -1044,6 +1079,7 @@ mod tests { scroll_offset: 0, pending_g: false, pending_space: false, + show_help: false, show_sidebar: true, hidden_files: HashSet::new(), diff_mode: DiffMode::Inline, @@ -1074,11 +1110,50 @@ mod tests { assert!(app.ui.show_sidebar); } + #[test] + fn handle_key_toggles_help_overlay_with_h() { + let mut app = App { + repo_root: PathBuf::from("."), + snapshot: RepoSnapshot { + branch: None, + files: vec![], + }, + ui: UiState { + selected: 0, + diff_cursor: 0, + scroll_offset: 0, + pending_g: false, + pending_space: false, + show_help: false, + show_sidebar: true, + hidden_files: HashSet::new(), + diff_mode: DiffMode::Inline, + panel_width: 80, + panel_height: 40, + focus: Panel::Files, + }, + diff_store: DiffStore { + cache: HashMap::new(), + loading: HashSet::new(), + }, + last_refresh: Instant::now(), + should_quit: false, + error_message: None, + }; + + app.handle_key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)); + assert!(app.ui.show_help); + + app.handle_key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)); + assert!(!app.ui.show_help); + } + #[test] fn toggle_all_files_staged_prefers_staging_partial_changes() { let app = App { repo_root: PathBuf::from("."), snapshot: RepoSnapshot { + branch: None, files: vec![FileStat { path: "tracked.txt".to_string(), additions: 1, @@ -1095,6 +1170,7 @@ mod tests { scroll_offset: 0, pending_g: false, pending_space: false, + show_help: false, show_sidebar: true, hidden_files: HashSet::new(), diff_mode: DiffMode::Inline, @@ -1128,6 +1204,7 @@ mod tests { let app = App { repo_root: PathBuf::from("."), snapshot: RepoSnapshot { + branch: None, files: vec![FileStat { path: "src/main.rs".to_string(), additions: 2, @@ -1144,6 +1221,7 @@ mod tests { scroll_offset: 0, pending_g: false, pending_space: false, + show_help: false, show_sidebar: true, hidden_files: HashSet::new(), diff_mode: DiffMode::Inline, diff --git a/src/git.rs b/src/git.rs index 4af042d..02a691b 100644 --- a/src/git.rs +++ b/src/git.rs @@ -46,6 +46,7 @@ pub struct FileContentSignature { #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct RepoSnapshot { + pub branch: Option, pub files: Vec, } @@ -134,6 +135,7 @@ pub fn repo_has_head(repo_root: &Path) -> Result { } pub fn load_snapshot(repo_root: &Path) -> Result { + let branch = current_branch(repo_root)?; let mut files = parse_status_porcelain(repo_root)?; let mut stats = parse_numstat(repo_root, true)?; @@ -153,7 +155,7 @@ pub fn load_snapshot(repo_root: &Path) -> Result { file.content_signature = file_content_signature(repo_root.join(&file.path)); } - Ok(RepoSnapshot { files }) + Ok(RepoSnapshot { branch, files }) } #[cfg_attr(not(test), allow(dead_code))] @@ -193,6 +195,33 @@ fn run_git_ok(repo: &Path, args: &[&str], context: &str) -> Result<()> { Ok(()) } +fn current_branch(repo_root: &Path) -> Result> { + let branch = run_git_utf8(repo_root, &["branch", "--show-current"])?; + if !branch.is_empty() { + return Ok(Some(branch)); + } + + let output = Command::new("git") + .current_dir(repo_root) + .args(["rev-parse", "--short", "HEAD"]) + .output() + .context("Failed to run git rev-parse --short HEAD")?; + + if !output.status.success() { + return Ok(None); + } + + let short_head = String::from_utf8(output.stdout) + .map(|text| text.trim().to_string()) + .context("git output not UTF-8")?; + + if short_head.is_empty() { + Ok(None) + } else { + Ok(Some(format!("detached@{short_head}"))) + } +} + fn parse_status_porcelain(repo_root: &Path) -> Result> { let output = Command::new("git") .current_dir(repo_root) diff --git a/src/ui.rs b/src/ui.rs index 3d11047..998fafd 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -5,8 +5,8 @@ use ratatui::{ style::{Color, Modifier, Style}, text::{Line, Span, Text}, widgets::{ - Block, Borders, List, ListItem, ListState, Paragraph, Scrollbar, ScrollbarOrientation, - ScrollbarState, + Block, Borders, Clear, List, ListItem, ListState, Paragraph, Scrollbar, + ScrollbarOrientation, ScrollbarState, }, Frame, }; @@ -21,6 +21,9 @@ pub fn render(frame: &mut Frame, app: &App) { render_title(frame, app, root_chunks[0]); render_body(frame, app, root_chunks[1]); + if app.ui.show_help { + render_help_overlay(frame, area); + } } fn render_title(frame: &mut Frame, app: &App, area: Rect) { @@ -32,6 +35,8 @@ fn render_title(frame: &mut Frame, app: &App, area: Rect) { let changed = app.files().len(); let mode_label = app.ui.diff_mode.label(); + let branch = app.snapshot.branch.as_deref().unwrap_or("detached"); + let branch_icon = "\u{e0a0}"; let title_text = if let Some(error) = &app.error_message { format!(" ripdiff [{repo_name}] ERROR: {error} ") @@ -41,7 +46,7 @@ fn render_title(frame: &mut Frame, app: &App, area: Rect) { Panel::Diff => "diff", }; format!( - " ripdiff [repo: {repo_name}] {changed} file{} changed mode: {mode_label} panel: {panel_label} │ Tab/h/l:panel j/k:nav gg/G:top/bottom s/S:stage-toggle []:hunk e:sidebar t:mode r:refresh q:quit", + " ripdiff [repo: {repo_name} {branch_icon} {branch}] {changed} file{} changed mode: {mode_label} panel: {panel_label} │ Tab:panel t:mode r:refresh h:help q:quit", if changed == 1 { "" } else { "s" }, ) }; @@ -54,6 +59,62 @@ fn render_title(frame: &mut Frame, app: &App, area: Rect) { frame.render_widget(Paragraph::new(title_text).style(style), area); } +fn render_help_overlay(frame: &mut Frame, area: Rect) { + let popup = centered_rect(area, 72, 22); + let text = Text::from(vec![ + Line::from(Span::styled( + " Keybindings", + Style::default().add_modifier(Modifier::BOLD), + )), + Line::from(" Tab / Shift-Tab Switch focused panel"), + Line::from(" j / k Move selection or scroll diff"), + Line::from(" gg / G Jump to top or bottom"), + Line::from(" Ctrl-d / Ctrl-u Scroll half a page in diff"), + Line::from(" [ / ] Jump to previous or next hunk"), + Line::from(" s / S Stage or unstage selected file / all files"), + Line::from(" Space e Hide or show the file sidebar"), + Line::from(" Enter Hide or show the selected file diff"), + Line::from(" t Toggle diff mode"), + Line::from(" r Refresh"), + Line::from(" h / ? / Esc Close this help"), + Line::from(" q Quit"), + Line::from(""), + Line::from(Span::styled( + " Symbols", + Style::default().add_modifier(Modifier::BOLD), + )), + Line::from(" ● Staged changes only"), + Line::from(" ○ Unstaged changes only"), + Line::from(" ◐ Mixed staged and unstaged changes"), + Line::from(" ⊘ Diff hidden for this file"), + Line::from(" M Modified"), + Line::from(" A Added"), + Line::from(" D Deleted"), + Line::from(" R Renamed"), + Line::from(" ? Untracked or unknown status"), + ]); + + let block = Block::default() + .title(" Help ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .style(Style::default().bg(Color::Black)); + + frame.render_widget(Clear, popup); + frame.render_widget(Paragraph::new(text).block(block), popup); +} + +fn centered_rect(area: Rect, width: u16, height: u16) -> Rect { + let popup_width = width.min(area.width.saturating_sub(2)).max(1); + let popup_height = height.min(area.height.saturating_sub(2)).max(1); + Rect { + x: area.x + area.width.saturating_sub(popup_width) / 2, + y: area.y + area.height.saturating_sub(popup_height) / 2, + width: popup_width, + height: popup_height, + } +} + fn render_body(frame: &mut Frame, app: &App, area: Rect) { if !app.ui.show_sidebar { render_diff_panel(frame, app, area); From c1195a0a91f364bff4e698cc640efbc6e32e021e Mon Sep 17 00:00:00 2001 From: martinabeleda Date: Fri, 13 Mar 2026 15:38:53 -0700 Subject: [PATCH 2/2] fix non-visible symbols in help panel --- src/ui.rs | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/ui.rs b/src/ui.rs index 998fafd..80127e3 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -60,8 +60,7 @@ fn render_title(frame: &mut Frame, app: &App, area: Rect) { } fn render_help_overlay(frame: &mut Frame, area: Rect) { - let popup = centered_rect(area, 72, 22); - let text = Text::from(vec![ + let lines = vec![ Line::from(Span::styled( " Keybindings", Style::default().add_modifier(Modifier::BOLD), @@ -92,7 +91,19 @@ fn render_help_overlay(frame: &mut Frame, area: Rect) { Line::from(" D Deleted"), Line::from(" R Renamed"), Line::from(" ? Untracked or unknown status"), - ]); + Line::from(" +N Added lines count"), + Line::from(" -N Deleted lines count"), + Line::from(" \u{e0a0} Current git branch"), + ]; + let popup_width = lines + .iter() + .map(display_width_for_line) + .max() + .unwrap_or(0) + .saturating_add(4) as u16; + let popup_height = lines.len().saturating_add(2) as u16; + let popup = centered_rect(area, popup_width, popup_height); + let text = Text::from(lines); let block = Block::default() .title(" Help ") @@ -104,6 +115,13 @@ fn render_help_overlay(frame: &mut Frame, area: Rect) { frame.render_widget(Paragraph::new(text).block(block), popup); } +fn display_width_for_line(line: &Line<'_>) -> usize { + line.spans + .iter() + .map(|span| display_width(span.content.as_ref())) + .sum() +} + fn centered_rect(area: Rect, width: u16, height: u16) -> Rect { let popup_width = width.min(area.width.saturating_sub(2)).max(1); let popup_height = height.min(area.height.saturating_sub(2)).max(1);