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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ All notable changes to AngKorGit are documented here. The format follows

## [Unreleased]

### Added
- **Ignore whitespace in the diff.** View options gains an Ignore whitespace checkbox
(`git diff -w`). It applies to the working copy, commits, and file history. Hunk and
line staging turn off while it is on, because the hunks on screen are not the patch
git would apply. (#38)

## [0.19.0] — 2026-09-28

The languages release. The diff highlighter learns Less, SCSS, Dockerfiles, Makefiles,
Expand Down
14 changes: 11 additions & 3 deletions apps/desktop/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -626,17 +626,22 @@ pub async fn diff_file(
file: String,
staged: bool,
contextLines: Option<u32>,
ignoreWhitespace: Option<bool>,
) -> AppResult<FileDiff> {
blocking(move || diff::file_diff(&path, &file, staged, contextLines.unwrap_or(3))).await
let ignore = ignoreWhitespace.unwrap_or(false);
blocking(move || diff::file_diff_with(&path, &file, staged, contextLines.unwrap_or(3), ignore))
.await
}

#[tauri::command]
pub async fn diff_commit(
path: String,
oid: String,
contextLines: Option<u32>,
ignoreWhitespace: Option<bool>,
) -> AppResult<Vec<FileDiff>> {
blocking(move || diff::commit_diff(&path, &oid, contextLines.unwrap_or(3))).await
let ignore = ignoreWhitespace.unwrap_or(false);
blocking(move || diff::commit_diff_with(&path, &oid, contextLines.unwrap_or(3), ignore)).await
}

#[tauri::command]
Expand All @@ -651,14 +656,17 @@ pub async fn diff_commit_file(
file: String,
oldPath: Option<String>,
contextLines: Option<u32>,
ignoreWhitespace: Option<bool>,
) -> AppResult<FileDiff> {
let ignore = ignoreWhitespace.unwrap_or(false);
blocking(move || {
diff::commit_file_diff(
diff::commit_file_diff_with(
&path,
&oid,
&file,
oldPath.as_deref(),
contextLines.unwrap_or(3),
ignore,
)
})
.await
Expand Down
56 changes: 47 additions & 9 deletions apps/desktop/src-tauri/src/core/diff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,16 @@ pub enum DiffTarget {
Staged,
}

fn base_opts(file: Option<&str>, context_lines: u32) -> DiffOptions {
fn base_opts(file: Option<&str>, context_lines: u32, ignore_whitespace: bool) -> DiffOptions {
let mut opts = DiffOptions::new();
let context_lines = context_lines.min(10_000_000);
opts.context_lines(context_lines)
.include_untracked(true)
.show_untracked_content(true)
.recurse_untracked_dirs(true);
if ignore_whitespace {
opts.ignore_whitespace(true);
}
if let Some(f) = file {
opts.pathspec(f);
}
Expand All @@ -40,8 +43,9 @@ fn make_diff<'a>(
target: DiffTarget,
file: Option<&str>,
context_lines: u32,
ignore_whitespace: bool,
) -> AppResult<Diff<'a>> {
let mut opts = base_opts(file, context_lines);
let mut opts = base_opts(file, context_lines, ignore_whitespace);
let diff = match target {
DiffTarget::Unstaged => repo.diff_index_to_workdir(None, Some(&mut opts))?,
DiffTarget::Staged => {
Expand Down Expand Up @@ -193,13 +197,23 @@ fn file_diff_from(
}

pub fn file_diff(path: &str, file: &str, staged: bool, context_lines: u32) -> AppResult<FileDiff> {
file_diff_with(path, file, staged, context_lines, false)
}

pub fn file_diff_with(
path: &str,
file: &str,
staged: bool,
context_lines: u32,
ignore_whitespace: bool,
) -> AppResult<FileDiff> {
let repo = super::repo::open(path)?;
let target = if staged {
DiffTarget::Staged
} else {
DiffTarget::Unstaged
};
let diff = make_diff(&repo, target, Some(file), context_lines)?;
let diff = make_diff(&repo, target, Some(file), context_lines, ignore_whitespace)?;
if diff.deltas().len() == 0 {
return Ok(FileDiff {
path: file.to_string(),
Expand All @@ -223,12 +237,13 @@ fn commit_tree_diff<'a>(
file: Option<&str>,
old_path: Option<&str>,
context_lines: u32,
ignore_whitespace: bool,
) -> AppResult<Diff<'a>> {
let commit = repo.find_commit(git2::Oid::from_str(oid)?)?;
let tree = commit.tree()?;
let parent_tree = commit.parent(0).ok().map(|p| p.tree()).transpose()?;

let mut opts = base_opts(file, context_lines);
let mut opts = base_opts(file, context_lines, ignore_whitespace);
if let Some(old) = old_path {
opts.pathspec(old);
}
Expand All @@ -239,9 +254,14 @@ fn commit_tree_diff<'a>(
Ok(diff)
}

pub fn commit_diff(path: &str, oid: &str, context_lines: u32) -> AppResult<Vec<FileDiff>> {
pub fn commit_diff_with(
path: &str,
oid: &str,
context_lines: u32,
ignore_whitespace: bool,
) -> AppResult<Vec<FileDiff>> {
let repo = super::repo::open(path)?;
let diff = commit_tree_diff(&repo, oid, None, None, context_lines)?;
let diff = commit_tree_diff(&repo, oid, None, None, context_lines, ignore_whitespace)?;
let count = diff.deltas().len();
let mut result = Vec::with_capacity(count);
for i in 0..count {
Expand All @@ -256,7 +276,7 @@ pub fn commit_files(path: &str, oid: &str) -> AppResult<Vec<CommitFileInfo>> {
}

pub fn files_of_commit(repo: &Repository, oid: &str) -> AppResult<Vec<CommitFileInfo>> {
let diff = commit_tree_diff(repo, oid, None, None, 0)?;
let diff = commit_tree_diff(repo, oid, None, None, 0, false)?;
let count = diff.deltas().len();
let mut result = Vec::with_capacity(count);
for i in 0..count {
Expand Down Expand Up @@ -297,9 +317,27 @@ pub fn commit_file_diff(
file: &str,
old_path: Option<&str>,
context_lines: u32,
) -> AppResult<FileDiff> {
commit_file_diff_with(path, oid, file, old_path, context_lines, false)
}

pub fn commit_file_diff_with(
path: &str,
oid: &str,
file: &str,
old_path: Option<&str>,
context_lines: u32,
ignore_whitespace: bool,
) -> AppResult<FileDiff> {
let repo = super::repo::open(path)?;
let diff = commit_tree_diff(&repo, oid, Some(file), old_path, context_lines)?;
let diff = commit_tree_diff(
&repo,
oid,
Some(file),
old_path,
context_lines,
ignore_whitespace,
)?;
let count = diff.deltas().len();
for i in 0..count {
let delta = diff
Expand Down Expand Up @@ -330,7 +368,7 @@ pub fn commit_file_diff(

pub fn staged_patch_text(path: &str) -> AppResult<String> {
let repo = super::repo::open(path)?;
let diff = make_diff(&repo, DiffTarget::Staged, None, 3)?;
let diff = make_diff(&repo, DiffTarget::Staged, None, 3, false)?;
let mut text = String::new();
diff.print(git2::DiffFormat::Patch, |_d, _h, line| {
match line.origin() {
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ pub mod test_api {
pub use crate::core::conflict::{
list as conflict_list, read as conflict_read, resolve as conflict_resolve,
};
pub use crate::core::diff::{commit_file_diff, commit_files, file_diff};
pub use crate::core::diff::{
commit_diff_with, commit_file_diff, commit_file_diff_with, commit_files, file_diff,
file_diff_with,
};
pub use crate::core::files::{file_contents, index_files, tree_files};
pub use crate::core::history::{
file_history, list as history, position as history_position, search as history_search,
Expand Down
30 changes: 30 additions & 0 deletions apps/desktop/src-tauri/tests/git_engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1223,6 +1223,36 @@ fn file_diff_reports_hunks() {
assert_eq!(context_lines, 2); // "one" and "three" around the change
}

#[test]
fn file_diff_can_ignore_whitespace() {
let repo = TempRepo::new();
repo.write("a.txt", "one\n two\nthree\n");
commit_all(&repo, "base");
repo.write("a.txt", "one\ntwo\nthree\n");

let raw = core::file_diff(repo.path(), "a.txt", false, 3).unwrap();
assert!(raw.additions + raw.deletions > 0);

let ignored = core::file_diff_with(repo.path(), "a.txt", false, 3, true).unwrap();
assert_eq!(ignored.additions, 0);
assert_eq!(ignored.deletions, 0);

repo.write("a.txt", "one\nTWO\nthree\n");
let token = core::file_diff_with(repo.path(), "a.txt", false, 3, true).unwrap();
assert!(token.additions + token.deletions > 0);

commit_all(&repo, "token");
repo.write("a.txt", "one\n two\nthree\n");
commit_all(&repo, "reindent");
repo.write("a.txt", "one\ntwo\nthree\n");
let oid = commit_all(&repo, "dedent");
let commit = core::commit_file_diff_with(repo.path(), &oid, "a.txt", None, 3, true).unwrap();
assert_eq!(commit.additions, 0);
assert_eq!(commit.deletions, 0);
let shown = core::commit_diff_with(repo.path(), &oid, 3, false).unwrap();
assert!(shown.iter().any(|d| d.additions + d.deletions > 0));
}

#[test]
fn rebase_linearizes_history() {
let repo = TempRepo::new();
Expand Down
18 changes: 15 additions & 3 deletions apps/desktop/src/core/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export const demoRepo: RepositoryInfo = {

export const DEMO_LESS_PATH = 'styles/theme.less';
export const DEMO_DOCKERFILE_PATH = 'Dockerfile';
export const DEMO_INDENT_PATH = 'src/indent.txt';

export const demoRecents: RecentRepository[] = [
{ path: '/Users/demo/projects/angkorgit', name: 'angkorgit', lastOpenedAt: 1754200000 },
Expand Down Expand Up @@ -187,6 +188,7 @@ export const demoStatus: StatusSummary = {
{ path: 'src/old-layout.tsx', origPath: null, staged: 'deleted', unstaged: null },
{ path: DEMO_LESS_PATH, origPath: null, staged: null, unstaged: 'modified' },
{ path: DEMO_DOCKERFILE_PATH, origPath: null, staged: null, unstaged: 'untracked' },
{ path: DEMO_INDENT_PATH, origPath: null, staged: null, unstaged: 'modified' },
],
branch: 'main',
ahead: 2,
Expand Down Expand Up @@ -456,7 +458,17 @@ function demoStatusDiff(
};
}

export function demoFileDiffFor(path: string): FileDiff {
export function demoFileDiffFor(path: string, ignoreWhitespace = false): FileDiff {
if (path === DEMO_INDENT_PATH) {
const diff = demoStatusDiff(path, 'modified', [
[' ', 'one'],
['-', ' two'],
['+', 'two'],
[' ', 'three'],
]);
if (!ignoreWhitespace) return diff;
return { ...diff, hunks: [], additions: 0, deletions: 0 };
}
if (path === demoLargeFileDiff.path) return demoLargeFileDiff;
if (path === DEMO_LESS_PATH) {
return demoStatusDiff(path, 'modified', [
Expand All @@ -481,8 +493,8 @@ export function demoFileDiffFor(path: string): FileDiff {
return { ...demoFileDiff, path };
}

export function demoCommitDiff(): FileDiff[] {
return [demoFileDiff];
export function demoCommitDiff(ignoreWhitespace = false): FileDiff[] {
return [demoFileDiffFor(demoFileDiff.path, ignoreWhitespace)];
}

export function demoCommitFiles(): CommitFileInfo[] {
Expand Down
38 changes: 31 additions & 7 deletions apps/desktop/src/core/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -537,13 +537,35 @@ export const ipc = {
return invoke('worktree_prune', { path });
},

async diffFile(path: string, file: string, staged: boolean, contextLines?: number): Promise<FileDiff> {
if (!isTauri()) return demo.demoFileDiffFor(file);
return invoke('diff_file', { path, file, staged, contextLines: contextLines ?? null });
async diffFile(
path: string,
file: string,
staged: boolean,
contextLines?: number,
ignoreWhitespace?: boolean,
): Promise<FileDiff> {
if (!isTauri()) return demo.demoFileDiffFor(file, ignoreWhitespace);
return invoke('diff_file', {
path,
file,
staged,
contextLines: contextLines ?? null,
ignoreWhitespace: ignoreWhitespace ?? null,
});
},
async diffCommit(path: string, oid: string, contextLines?: number): Promise<FileDiff[]> {
if (!isTauri()) return demo.demoCommitDiff();
return invoke('diff_commit', { path, oid, contextLines: contextLines ?? null });
async diffCommit(
path: string,
oid: string,
contextLines?: number,
ignoreWhitespace?: boolean,
): Promise<FileDiff[]> {
if (!isTauri()) return demo.demoCommitDiff(ignoreWhitespace);
return invoke('diff_commit', {
path,
oid,
contextLines: contextLines ?? null,
ignoreWhitespace: ignoreWhitespace ?? null,
});
},
async commitFiles(path: string, oid: string): Promise<CommitFileInfo[]> {
if (!isTauri()) return demo.demoCommitFiles();
Expand All @@ -567,14 +589,16 @@ export const ipc = {
file: string,
oldPath?: string | null,
contextLines?: number,
ignoreWhitespace?: boolean,
): Promise<FileDiff> {
if (!isTauri()) return demo.demoFileDiffFor(file);
if (!isTauri()) return demo.demoFileDiffFor(file, ignoreWhitespace);
return invoke('diff_commit_file', {
path,
oid,
file,
oldPath: oldPath ?? null,
contextLines: contextLines ?? null,
ignoreWhitespace: ignoreWhitespace ?? null,
});
},
async stagedPatch(path: string): Promise<string> {
Expand Down
Loading
Loading