Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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 |
Expand All @@ -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 |
Expand All @@ -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() {
Expand All @@ -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
82 changes: 80 additions & 2 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
pub diff_mode: DiffMode,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
Expand Down Expand Up @@ -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(),
Expand All @@ -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;
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
31 changes: 30 additions & 1 deletion src/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ pub struct FileContentSignature {

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RepoSnapshot {
pub branch: Option<String>,
pub files: Vec<FileStat>,
}

Expand Down Expand Up @@ -134,6 +135,7 @@ pub fn repo_has_head(repo_root: &Path) -> Result<bool> {
}

pub fn load_snapshot(repo_root: &Path) -> Result<RepoSnapshot> {
let branch = current_branch(repo_root)?;
let mut files = parse_status_porcelain(repo_root)?;
let mut stats = parse_numstat(repo_root, true)?;

Expand All @@ -153,7 +155,7 @@ pub fn load_snapshot(repo_root: &Path) -> Result<RepoSnapshot> {
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))]
Expand Down Expand Up @@ -193,6 +195,33 @@ fn run_git_ok(repo: &Path, args: &[&str], context: &str) -> Result<()> {
Ok(())
}

fn current_branch(repo_root: &Path) -> Result<Option<String>> {
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<Vec<FileStat>> {
let output = Command::new("git")
.current_dir(repo_root)
Expand Down
Loading
Loading