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
404 changes: 404 additions & 0 deletions Cargo.lock

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ clap = { version = "4", features = ["derive"] }
crossterm = { version = "0.28", features = ["event-stream", "libc", "use-dev-tty"] }
dirs = "6"
ratatui = "0.29"
ratatui-image = { version = "=8.0.1", default-features = false }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp", "ico", "tiff"] }
serde = { version = "1", features = ["derive"] }
serde_yaml = "0.9"
serde_json = "1"
Expand Down Expand Up @@ -43,6 +45,7 @@ unicode-width = "0.2"
tui-textarea = { version = "0.7", features = ["crossterm"] }
textwrap = "0.16.2"
libc = "0.2"
strip-ansi-escapes = "0.2"

[profile.release]
lto = true
Expand Down
62 changes: 62 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,44 @@ Then run:
lazygitrs
```

Use `lazygitrs --commits` to start at the checked-out HEAD commit. Press
`Ctrl+G` to toggle between working-tree changes and HEAD (including from a
focused diff). If filters or pagination hide HEAD, it opens a separate HEAD
history view without clearing your filters. Remap or disable the shortcut with
`keybinding.universal.toggleWorkingTreeAndHead` (`"<c-g>"` by default; `""` disables it).

### File tree navigation

In Files, commit/stash file lists, and compare mode, press backtick (`` ` ``) to
toggle the tree view. With a tree active:

- `-` folds/unfolds the selected directory (including the root).
- `Enter` focuses the selected file or combined directory diff without changing
your normal/half/full layout; `Esc` returns to the file list.
- `,` / `.` select the parent / first visible child.
- `<` / `>` select the previous / next sibling, skipping nested descendants.
Hierarchy navigation also works while the diff is focused.

These shortcuts are configurable under `keybinding.universal` in your config:

```yaml
keybinding:
universal:
foldDirectory: "-"
treeParent: ","
treeChild: "."
treePrevSibling: "<"
treeNextSibling: ">"
```

Set a binding to `""` to disable it. Collapsed directories must be unfolded
before their children can be selected. Tree-navigation actions appear in `?`
only while a tree is active. The footer shows just the fold/unfold shortcut
(default `-`) in tree views, not hierarchy navigation or commit details.
`'` (apostrophe) toggles commit details, also shown on the panel’s top-right
border; explicitly remapping a tree action to `'` will override it while
the tree is active.

### Upgrade

Detects how you installed (brew / npm / bun / cargo / install.sh) and upgrades in place:
Expand Down Expand Up @@ -74,6 +112,30 @@ lazygitrs upgrade 0.0.32 # specific version
- [x] 🎨 Themes + Theme-Picker!
- [x] **Grep diff contents** — `Ctrl-F` in Files / Commit Files / Compare searches hunk lines in-context, `Enter` jumps to the file in the current list.

### Image diffs

Selecting a PNG, JPEG, GIF (first frame), WebP, BMP, ICO, or TIFF file shows
**Before / After** previews in Kitty and Ghostty. Added/deleted files show the
available image full-width, labeled **Added** or **Deleted**. Staged previews use the index;
commit-file and ref comparisons use the corresponding Git blobs.

Other binaries, unsupported/corrupt images, files over 20 MiB, and unsupported
terminal environments keep the striped binary placeholder. Images are decoded
with memory/dimension limits and resized in the background. SVG and video
previews are not included yet.

Folder previews in the file tree mix text diffs with inline image sections.
Each image section is capped at 12 rows; visible sections load in the background
(two at a time) and offscreen pixels are released. This works in split/unified
and wrapped views, including commit/stash folders and ref-comparison folders.
Whole-commit overview buffers still retain binary placeholders. Inline images
require Kitty/Ghostty placeholder graphics; other terminals keep stripes.

Set `LAZYGITRS_IMAGE_PREVIEW=off` to disable image previews. tmux, screen, and
Zellij currently fall back to stripes. iTerm2/WezTerm and Sixel are experimental
(`LAZYGITRS_IMAGE_PREVIEW=experimental`); their overlay cleanup is not yet fully
verified. Nested editor launches avoid terminal capability queries.

### Configuration

Config goes in `~/.config/lazygitrs/config.yml` or `~/.config/lazygit/config.yml` — both work, using either only won't break anything so you can reference the [original lazygit config guide](https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md).
Expand Down
15 changes: 11 additions & 4 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,16 @@ pub struct App {
pub config: AppConfig,
pub repo_path: PathBuf,
pub filter_path: Option<PathBuf>,
pub start_in_commits: bool,
}

impl App {
pub fn new(repo_path: PathBuf, debug: bool, filter_path: Option<PathBuf>) -> Result<Self> {
pub fn new(
repo_path: PathBuf,
debug: bool,
filter_path: Option<PathBuf>,
start_in_commits: bool,
) -> Result<Self> {
let config = AppConfig::load(debug)?;

// Validate git repo
Expand All @@ -25,6 +31,7 @@ impl App {
config,
repo_path,
filter_path,
start_in_commits,
})
}

Expand All @@ -36,9 +43,9 @@ impl App {
self.config.app_state.add_recent_repo(&repo_str);
let _ = self.config.save_state();

// Pass `-f` into Gui::new so the initial model stream loads filtered
// commits immediately (no wait for full refresh + second git log).
let mut gui = Gui::new(self.config, git, self.filter_path)?;
// Pass `-f` and start_in_commits into Gui::new so the initial model stream loads
// filtered commits or commits tab immediately.
let mut gui = Gui::new(self.config, git, self.filter_path, self.start_in_commits)?;
gui.run()?;

Ok(())
Expand Down
48 changes: 48 additions & 0 deletions src/config/keybindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,18 @@ pub struct UniversalKeybinding {
pub reset_side_panel: String,
#[serde(rename = "toggleDiffViewLayout")]
pub toggle_diff_view_layout: String,
#[serde(rename = "toggleWorkingTreeAndHead")]
pub toggle_working_tree_and_head: String,
#[serde(rename = "foldDirectory")]
pub fold_directory: String,
#[serde(rename = "treeParent")]
pub tree_parent: String,
#[serde(rename = "treeChild")]
pub tree_child: String,
#[serde(rename = "treePrevSibling")]
pub tree_prev_sibling: String,
#[serde(rename = "treeNextSibling")]
pub tree_next_sibling: String,
}

impl Default for UniversalKeybinding {
Expand Down Expand Up @@ -181,6 +193,12 @@ impl Default for UniversalKeybinding {
main_panel_full: "<a-j>".into(),
reset_side_panel: "<a-r>".into(),
toggle_diff_view_layout: "\\".into(),
toggle_working_tree_and_head: "<c-g>".into(),
fold_directory: "-".into(),
tree_parent: ",".into(),
tree_child: ".".into(),
tree_prev_sibling: "<".into(),
tree_next_sibling: ">".into(),
}
}
}
Expand Down Expand Up @@ -448,3 +466,33 @@ pub fn parse_key(s: &str) -> Option<KeyEvent> {

None
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_tree_keybindings_defaults_and_deserialization() {
let default_config = KeybindingConfig::default();
assert_eq!(default_config.universal.fold_directory, "-");
assert_eq!(default_config.universal.tree_parent, ",");
assert_eq!(default_config.universal.tree_child, ".");
assert_eq!(default_config.universal.tree_prev_sibling, "<");
assert_eq!(default_config.universal.tree_next_sibling, ">");

let yaml = r#"
universal:
foldDirectory: "_"
treeParent: "p"
treeChild: "c"
treePrevSibling: "h"
treeNextSibling: "l"
"#;
let parsed: KeybindingConfig = serde_yaml::from_str(yaml).expect("valid yaml");
assert_eq!(parsed.universal.fold_directory, "_");
assert_eq!(parsed.universal.tree_parent, "p");
assert_eq!(parsed.universal.tree_child, "c");
assert_eq!(parsed.universal.tree_prev_sibling, "h");
assert_eq!(parsed.universal.tree_next_sibling, "l");
}
}
14 changes: 14 additions & 0 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,20 @@ pub struct AppConfig {
pub state_path: PathBuf,
}

impl Default for AppConfig {
fn default() -> Self {
Self {
debug: false,
version: env!("CARGO_PKG_VERSION").to_string(),
user_config: UserConfig::default(),
app_state: AppState::default(),
config_dir: PathBuf::from("."),
state_dir: PathBuf::from("."),
state_path: PathBuf::from("state.yml"),
}
}
}

impl AppConfig {
pub fn load(debug: bool) -> Result<Self> {
let home_dir = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
Expand Down
52 changes: 50 additions & 2 deletions src/git/commit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,40 @@ impl GitCommands {
self.load_filtered_commits_page(&CommitFilter::default(), limit, skip)
}

/// Load the symmetric difference of two commit revisions in topological order.
/// Left marks A-only commits; Right marks B-only commits. A zero limit is
/// unlimited, matching the other paged commit loaders.
pub fn load_compare_commits(
&self,
ref_a: &str,
ref_b: &str,
limit: usize,
skip: usize,
) -> Result<Vec<Commit>> {
let range = format!(
"{}...{}",
self.resolve_commit_ref(ref_a)?,
self.resolve_commit_ref(ref_b)?
);
// NUL field separators preserve pipes in commit subjects and identities.
// %m is '<' for the left side and '>' for the right side of A...B.
let mut cmd = self.git().args(&[
"log",
"--left-right",
"--topo-order",
"--no-show-signature",
"--format=%H%x00%s%x00%an%x00%ae%x00%at%x00%P%x00%D%x00%m",
]);
if limit > 0 {
cmd = cmd.arg(&format!("--max-count={limit}"));
}
if skip > 0 {
cmd = cmd.arg(&format!("--skip={skip}"));
}
let result = cmd.arg(&range).arg("--").run_expecting_success()?;
self.parse_commit_log_fields(&result, '\0')
}

pub fn load_filtered_commits_page(
&self,
filter: &CommitFilter,
Expand Down Expand Up @@ -165,6 +199,14 @@ impl GitCommands {
}

fn parse_commit_log(&self, result: &crate::os::cmd::CmdResult) -> Result<Vec<Commit>> {
self.parse_commit_log_fields(result, '|')
}

fn parse_commit_log_fields(
&self,
result: &crate::os::cmd::CmdResult,
separator: char,
) -> Result<Vec<Commit>> {
if !result.success {
return Ok(Vec::new());
}
Expand All @@ -173,7 +215,9 @@ impl GitCommands {
// `git log @{u}..HEAD` per page parse (filter apply hot path).
let mut commits = Vec::new();
for line in result.stdout.lines() {
let parts: Vec<&str> = line.splitn(7, '|').collect();
let parts: Vec<&str> = line
.splitn(if separator == '\0' { 8 } else { 7 }, separator)
.collect();
if parts.len() < 6 {
continue;
}
Expand Down Expand Up @@ -201,7 +245,11 @@ impl GitCommands {
author_email,
unix_timestamp,
parents,
divergence: Divergence::None,
divergence: match parts.get(7).copied() {
Some("<") => Divergence::Left,
Some(">") => Divergence::Right,
_ => Divergence::None,
},
});
}

Expand Down
26 changes: 25 additions & 1 deletion src/git/diff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,30 @@ impl GitCommands {
}
}

/// Counts commits unique to A and B, respectively (A's ahead/behind B).
/// Both refs must resolve to commits; tree-only comparisons have no ancestry.
pub fn diff_refs_ahead_behind(&self, ref_a: &str, ref_b: &str) -> Result<(usize, usize)> {
let range = format!(
"{}...{}",
self.resolve_commit_ref(ref_a)?,
self.resolve_commit_ref(ref_b)?
);
let result = self
.git()
.args(&["rev-list", "--left-right", "--count", &range, "--"])
.run_expecting_success()?;
let mut counts = result.stdout.split_whitespace();
let ahead = counts
.next()
.ok_or_else(|| anyhow::anyhow!("Missing ahead count"))?
.parse()?;
let behind = counts
.next()
.ok_or_else(|| anyhow::anyhow!("Missing behind count"))?
.parse()?;
Ok((ahead, behind))
}

/// Get the list of files changed between two refs (for diff/compare mode).
pub fn diff_refs_files(
&self,
Expand Down Expand Up @@ -486,7 +510,7 @@ impl GitCommands {
}
}

fn diff_paths_for_label(path: &str) -> Vec<&str> {
pub(crate) fn diff_paths_for_label(path: &str) -> Vec<&str> {
match path.split_once(" -> ") {
Some((old, new)) => vec![old, new],
None => vec![path],
Expand Down
Loading
Loading