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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ This opens the side-by-side review so you can check exactly what changed in your
- Side-by-side panes with independent horizontal scroll offsets
- Keyboard and mouse navigation (including wheel + shift-wheel)
- Vim-like motion navigation (`h`/`j`/`k`/`l`, `g`/`G`, `Ctrl+u`/`Ctrl+d`)
- Configurable key bindings
- In-diff search (`/` + Enter, then `n` / `N` to navigate matches)
- Per-file reviewed toggles (`r`) with local persistence under `.git`
- Language-aware syntax highlighting and line-level add/delete tinting
Expand All @@ -65,6 +66,7 @@ deff --strategy range --base origin/main --head HEAD
deff --strategy range --base origin/main --include-uncommitted
deff --only-uncommitted
deff --theme dark
deff --config ~/.config/deff/config.toml
```

Show help:
Expand Down Expand Up @@ -121,6 +123,21 @@ Theme selection:
- Use `--theme auto|dark|light` to control rendering for your terminal.
- `--theme` takes precedence over `DEFF_THEME=dark|light`.

Key binding config:

- Default filesystem locations: `~/.config/deff/config.toml` or `$XDG_CONFIG_HOME/deff/config.toml`
- `--config <path>` to load specific config file
- `--no-config` to ignore config files
- Supported actions: `quit`, `previous_file`, `next_file`, `scroll_up`, `scroll_down`, `page_up`, `page_down`, `top`, `bottom`, `search`, `next_match`, `previous_match`, `next_hunk`, `previous_hunk`, `toggle_reviewed`
- Key names: printable characters (`"j"`), arrows (`"left"`), paging keys (`"pageup"`), modifiers (`"ctrl-u"`, `"alt-j"`)
- Entries under `[keybindings]` replace that action's defaults; omitted actions keep default bindings

```toml
[keybindings]
page_down = "J"
page_up = "K"
```

Custom syntax grammars:

- `deff` loads syntect defaults, bundled deff grammars, plus any extra `.sublime-syntax` files found in:
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@
- `src/lib.rs`: top-level orchestration (`run`) and dependency wiring.
- `src/main.rs`: binary entrypoint and error exit handling.
- `src/cli.rs`: clap definitions and argument validation into `CliOptions`.
- `src/config.rs`: user config loading and key binding parsing.
- `src/model.rs`: shared enums/structs for comparison metadata and file views.
- `src/git.rs`: git command execution plus comparison strategy resolution.
- `src/diff.rs`: file descriptor discovery, hunk highlight parsing, and view construction.
- `src/render.rs`: layout calculations and frame rendering with syntax highlighting.
- `src/app.rs`: state transitions for keyboard/mouse navigation.
- `src/terminal.rs`: TUI lifecycle and event loop plumbing.
- `src/text.rs`: pure string-width and formatting helpers.

78 changes: 19 additions & 59 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use std::collections::HashSet;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};

use crate::{
config::{Action, KeyBindings},
model::{DiffFileView, PaneOffsets, PaneSide},
render::{create_frame_layout, get_body_line_count, get_max_pane_offsets, get_pane_for_column},
};
Expand Down Expand Up @@ -441,6 +442,7 @@ pub(crate) fn handle_keypress(
files: &[DiffFileView],
app: &mut AppState,
rows: u16,
keybindings: &KeyBindings,
) -> KeypressOutcome {
if key.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
Expand Down Expand Up @@ -470,119 +472,77 @@ pub(crate) fn handle_keypress(
return KeypressOutcome::default();
}

match key.code {
KeyCode::Char('q') | KeyCode::Char('Q') => KeypressOutcome {
match keybindings.action_for_key(key) {
Some(Action::Quit) => KeypressOutcome {
should_quit: true,
review_toggled: None,
},
KeyCode::Left => {
Some(Action::PreviousFile) => {
if move_file(-1, files, app) {
app.refresh_search_matches_for_current_file(files);
}
KeypressOutcome::default()
}
KeyCode::Right => {
Some(Action::NextFile) => {
if move_file(1, files, app) {
app.refresh_search_matches_for_current_file(files);
}
KeypressOutcome::default()
}
KeyCode::Up => {
Some(Action::ScrollUp) => {
move_scroll(-1, files, app, rows);
KeypressOutcome::default()
}
KeyCode::Down => {
Some(Action::ScrollDown) => {
move_scroll(1, files, app, rows);
KeypressOutcome::default()
}
KeyCode::Char('h') => {
if move_file(-1, files, app) {
app.refresh_search_matches_for_current_file(files);
}
KeypressOutcome::default()
}
KeyCode::Char('l') => {
if move_file(1, files, app) {
app.refresh_search_matches_for_current_file(files);
}
KeypressOutcome::default()
}
KeyCode::Char('k') => {
move_scroll(-1, files, app, rows);
KeypressOutcome::default()
}
KeyCode::Char('j') => {
move_scroll(1, files, app, rows);
KeypressOutcome::default()
}
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
Some(Action::PageUp) => {
let page_size = get_body_line_count(rows as usize).max(1) as isize;
move_scroll(-page_size, files, app, rows);
KeypressOutcome::default()
}
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
Some(Action::PageDown) => {
let page_size = get_body_line_count(rows as usize).max(1) as isize;
move_scroll(page_size, files, app, rows);
KeypressOutcome::default()
}
KeyCode::PageUp => {
let page_size = get_body_line_count(rows as usize).max(1) as isize;
move_scroll(-page_size, files, app, rows);
KeypressOutcome::default()
}
KeyCode::PageDown => {
let page_size = get_body_line_count(rows as usize).max(1) as isize;
move_scroll(page_size, files, app, rows);
KeypressOutcome::default()
}
KeyCode::Home => {
Some(Action::Top) => {
scroll_to_top(app);
KeypressOutcome::default()
}
KeyCode::End => {
scroll_to_bottom(files, app, rows);
KeypressOutcome::default()
}
KeyCode::Char('g') if key.modifiers.contains(KeyModifiers::SHIFT) => {
scroll_to_bottom(files, app, rows);
KeypressOutcome::default()
}
KeyCode::Char('G') => {
Some(Action::Bottom) => {
scroll_to_bottom(files, app, rows);
KeypressOutcome::default()
}
KeyCode::Char('g') => {
scroll_to_top(app);
KeypressOutcome::default()
}
KeyCode::Char('/') => {
Some(Action::Search) => {
app.enter_search_input_mode();
KeypressOutcome::default()
}
KeyCode::Char('n') => {
Some(Action::NextMatch) => {
app.jump_to_search_match(files, rows, true);
KeypressOutcome::default()
}
KeyCode::Char('N') => {
Some(Action::PreviousMatch) => {
app.jump_to_search_match(files, rows, false);
KeypressOutcome::default()
}
KeyCode::Char('}') => {
Some(Action::NextHunk) => {
app.jump_to_hunk(files, rows, true);
KeypressOutcome::default()
}
KeyCode::Char('{') => {
Some(Action::PreviousHunk) => {
app.jump_to_hunk(files, rows, false);
KeypressOutcome::default()
}
KeyCode::Char('r') => {
Some(Action::ToggleReviewed) => {
let reviewed = app.toggle_current_file_reviewed();
KeypressOutcome {
should_quit: false,
review_toggled: Some((app.file_index, reviewed)),
}
}
_ => KeypressOutcome::default(),
None => KeypressOutcome::default(),
}
}

Expand Down
36 changes: 35 additions & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use anyhow::{Result, bail};
use clap::Parser;
use std::path::PathBuf;

use crate::model::{StrategyArg, StrategyId, ThemeMode};

Expand All @@ -17,6 +18,7 @@ const DEFAULT_HEAD_REF: &str = "HEAD";
deff --strategy range --base <git-ref> [--head <git-ref>]
deff --strategy range --base <git-ref> --include-uncommitted
deff --theme dark
deff --config ~/.config/deff/config.toml

Key bindings:
h / left-arrow previous file
Expand All @@ -33,7 +35,11 @@ Key bindings:
/ start in-diff search
n / N next / previous search match
r toggle reviewed for current file
q quit"#
q quit

Config:
Reads ~/.config/deff/config.toml or $XDG_CONFIG_HOME/deff/config.toml by default.
Use [keybindings] entries such as page_down = "J"."#
)]
struct Cli {
#[arg(long, value_enum)]
Expand All @@ -48,6 +54,10 @@ struct Cli {
only_uncommitted: bool,
#[arg(long, value_enum, default_value_t = ThemeMode::Auto)]
theme: ThemeMode,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long)]
no_config: bool,
}

#[derive(Clone, Debug)]
Expand All @@ -58,6 +68,8 @@ pub(crate) struct CliOptions {
pub(crate) include_uncommitted: bool,
pub(crate) only_uncommitted: bool,
pub(crate) theme_mode: ThemeMode,
pub(crate) config_path: Option<PathBuf>,
pub(crate) no_config: bool,
}

impl TryFrom<Cli> for CliOptions {
Expand Down Expand Up @@ -106,13 +118,19 @@ impl TryFrom<Cli> for CliOptions {
bail!("--include-uncommitted currently requires --head HEAD");
}

if value.no_config && value.config.is_some() {
bail!("--config cannot be combined with --no-config");
}

Ok(Self {
strategy_id,
base_ref: value.base,
head_ref: value.head,
include_uncommitted: value.include_uncommitted,
only_uncommitted: value.only_uncommitted,
theme_mode: value.theme,
config_path: value.config,
no_config: value.no_config,
})
}
}
Expand All @@ -134,6 +152,8 @@ mod tests {
include_uncommitted: false,
only_uncommitted: false,
theme: ThemeMode::Auto,
config: None,
no_config: false,
}
}

Expand Down Expand Up @@ -176,4 +196,18 @@ mod tests {
.contains("--only-uncommitted cannot be combined with --head")
);
}

#[test]
fn rejects_config_path_with_no_config() {
let mut cli = base_cli();
cli.config = Some(PathBuf::from("config.toml"));
cli.no_config = true;

let error = CliOptions::try_from(cli).expect_err("config options should be rejected");
assert!(
error
.to_string()
.contains("--config cannot be combined with --no-config")
);
}
}
Loading