diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fc7ee53..8b1d8569 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index d304d55b..d0f944eb 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -626,8 +626,11 @@ pub async fn diff_file( file: String, staged: bool, contextLines: Option, + ignoreWhitespace: Option, ) -> AppResult { - 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] @@ -635,8 +638,10 @@ pub async fn diff_commit( path: String, oid: String, contextLines: Option, + ignoreWhitespace: Option, ) -> AppResult> { - 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] @@ -651,14 +656,17 @@ pub async fn diff_commit_file( file: String, oldPath: Option, contextLines: Option, + ignoreWhitespace: Option, ) -> AppResult { + 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 diff --git a/apps/desktop/src-tauri/src/core/diff.rs b/apps/desktop/src-tauri/src/core/diff.rs index bee1b0a2..4b37e61f 100644 --- a/apps/desktop/src-tauri/src/core/diff.rs +++ b/apps/desktop/src-tauri/src/core/diff.rs @@ -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); } @@ -40,8 +43,9 @@ fn make_diff<'a>( target: DiffTarget, file: Option<&str>, context_lines: u32, + ignore_whitespace: bool, ) -> AppResult> { - 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 => { @@ -193,13 +197,23 @@ fn file_diff_from( } pub fn file_diff(path: &str, file: &str, staged: bool, context_lines: u32) -> AppResult { + 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 { 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(), @@ -223,12 +237,13 @@ fn commit_tree_diff<'a>( file: Option<&str>, old_path: Option<&str>, context_lines: u32, + ignore_whitespace: bool, ) -> AppResult> { 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); } @@ -239,9 +254,14 @@ fn commit_tree_diff<'a>( Ok(diff) } -pub fn commit_diff(path: &str, oid: &str, context_lines: u32) -> AppResult> { +pub fn commit_diff_with( + path: &str, + oid: &str, + context_lines: u32, + ignore_whitespace: bool, +) -> AppResult> { 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 { @@ -256,7 +276,7 @@ pub fn commit_files(path: &str, oid: &str) -> AppResult> { } pub fn files_of_commit(repo: &Repository, oid: &str) -> AppResult> { - 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 { @@ -297,9 +317,27 @@ pub fn commit_file_diff( file: &str, old_path: Option<&str>, context_lines: u32, +) -> AppResult { + 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 { 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 @@ -330,7 +368,7 @@ pub fn commit_file_diff( pub fn staged_patch_text(path: &str) -> AppResult { 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() { diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 2a47c6b6..f5b7ec49 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -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, diff --git a/apps/desktop/src-tauri/tests/git_engine.rs b/apps/desktop/src-tauri/tests/git_engine.rs index 1f1f56eb..116bfec9 100644 --- a/apps/desktop/src-tauri/tests/git_engine.rs +++ b/apps/desktop/src-tauri/tests/git_engine.rs @@ -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(); diff --git a/apps/desktop/src/core/demo.ts b/apps/desktop/src/core/demo.ts index 4f9d2bbb..c8cf72cb 100644 --- a/apps/desktop/src/core/demo.ts +++ b/apps/desktop/src/core/demo.ts @@ -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 }, @@ -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, @@ -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', [ @@ -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[] { diff --git a/apps/desktop/src/core/ipc.ts b/apps/desktop/src/core/ipc.ts index fbf92292..cddaa509 100644 --- a/apps/desktop/src/core/ipc.ts +++ b/apps/desktop/src/core/ipc.ts @@ -537,13 +537,35 @@ export const ipc = { return invoke('worktree_prune', { path }); }, - async diffFile(path: string, file: string, staged: boolean, contextLines?: number): Promise { - 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 { + 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 { - 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 { + 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 { if (!isTauri()) return demo.demoCommitFiles(); @@ -567,14 +589,16 @@ export const ipc = { file: string, oldPath?: string | null, contextLines?: number, + ignoreWhitespace?: boolean, ): Promise { - 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 { diff --git a/apps/desktop/src/features/diff/DiffPanel.tsx b/apps/desktop/src/features/diff/DiffPanel.tsx index e6ec8ad1..2e4c5016 100644 --- a/apps/desktop/src/features/diff/DiffPanel.tsx +++ b/apps/desktop/src/features/diff/DiffPanel.tsx @@ -1,7 +1,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { motion } from 'framer-motion'; import { toast } from 'sonner'; -import { ChevronLeft, ChevronRight, Columns2, Copy, FileText, History, Minus, Plus, Rows3, SearchCheck, SlidersHorizontal, Sparkles, TextSelect, Trash2, UserRoundSearch, WholeWord, WrapText, X } from 'lucide-react'; +import { ChevronLeft, ChevronRight, Columns2, Copy, FileText, History, Minus, Plus, Rows3, SearchCheck, SlidersHorizontal, Space, Sparkles, TextSelect, Trash2, UserRoundSearch, WholeWord, WrapText, X } from 'lucide-react'; import type { CommitFileInfo, FileDiff } from '@angkorgit/core'; import { aiCapabilities, hasCommittedHistory, hasReviewableText, hashText, locateDiffLine, patchTextOf, PROJECT_REVIEW_FILE } from '@angkorgit/core'; import { @@ -80,6 +80,8 @@ export function DiffPanel({ target }: { target: CenterDiffTarget }) { const setDiffView = useUi((s) => s.setDiffView); const wordDiff = useUi((s) => s.wordDiff); const setWordDiff = useUi((s) => s.setWordDiff); + const ignoreWhitespace = useUi((s) => s.ignoreWhitespace); + const setIgnoreWhitespace = useUi((s) => s.setIgnoreWhitespace); const fullFileDiff = useUi((s) => s.fullFileDiff); const setFullFileDiff = useUi((s) => s.setFullFileDiff); const wrapLines = useUi((s) => s.wrapLines); @@ -116,10 +118,17 @@ export function DiffPanel({ target }: { target: CenterDiffTarget }) { const diffRef = useRef(null); diffRef.current = diff; - const fetchDiff = async (contextLines?: number): Promise => { + const fetchDiff = async (contextLines?: number, ignore = ignoreWhitespace): Promise => { if (target.unchanged) return ipc.fileContents(path, target.path, target.oid ?? null); if (target.oid) { - const result = await ipc.commitFileDiff(path, target.oid, target.path, target.oldPath ?? null, contextLines); + const result = await ipc.commitFileDiff( + path, + target.oid, + target.path, + target.oldPath ?? null, + contextLines, + ignore, + ); const untouched = result.hunks.length === 0 && result.additions === 0 && @@ -128,7 +137,7 @@ export function DiffPanel({ target }: { target: CenterDiffTarget }) { !result.isImage; return untouched ? null : result; } - return ipc.diffFile(path, target.path, target.staged ?? false, contextLines); + return ipc.diffFile(path, target.path, target.staged ?? false, contextLines, ignore); }; const [commitFileList, setCommitFileList] = useState([]); const commitFiles = useMemo(() => commitFileList.map((f) => f.path), [commitFileList]); @@ -244,7 +253,7 @@ export function DiffPanel({ target }: { target: CenterDiffTarget }) { if (!path) return; let cancelled = false; const seq = ++requestSeq.current; - const key = `${path}|${target.path}|${target.oid ?? ''}|${target.staged ?? false}|${target.unchanged ?? false}|${fullFileDiff}|${reloadToken}`; + const key = `${path}|${target.path}|${target.oid ?? ''}|${target.staged ?? false}|${target.unchanged ?? false}|${fullFileDiff}|${ignoreWhitespace}|${reloadToken}`; if (loadedKey.current !== key) setLoading(true); void fetchDiff(fullFileDiff ? 10_000_000 : undefined) .then((result) => { @@ -268,7 +277,7 @@ export function DiffPanel({ target }: { target: CenterDiffTarget }) { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [path, target.path, target.oid, target.staged, target.unchanged, fullFileDiff, reloadToken, statusSignature]); + }, [path, target.path, target.oid, target.staged, target.unchanged, fullFileDiff, ignoreWhitespace, reloadToken, statusSignature]); useEffect( () => () => { @@ -298,7 +307,7 @@ export function DiffPanel({ target }: { target: CenterDiffTarget }) { const run = useAiWork.getState().startFileAi(key, kind); const stillRunning = () => useAiWork.getState().isFileAiRun(key, run); try { - const source = !fullFileDiff && diff ? diff : await fetchDiff(); + const source = !fullFileDiff && !ignoreWhitespace && diff ? diff : await fetchDiff(undefined, false); if (!stillRunning()) return; if (!hasReviewableText(source)) { toast.info('This file has no text changes to send'); @@ -452,13 +461,19 @@ export function DiffPanel({ target }: { target: CenterDiffTarget }) { - + @@ -466,19 +481,28 @@ export function DiffPanel({ target }: { target: CenterDiffTarget }) { View options - setWordDiff(v === true)}> - Word diff + setWordDiff(v === true)}> + Word diff + + setIgnoreWhitespace(v === true)}> + Ignore whitespace setWrapLines(v === true)} > - Wrap long lines + Wrap long lines - setFullFileDiff(v === true)}> - Show whole file + setFullFileDiff(v === true)}> + Show whole file + {ignoreWhitespace && ( +

+ Hunk and line stage stay off. These hunks are not the patch git would apply. +

+ )} {textDiff && wrapUnavailable(textDiff) && (

Wrapping stays off for large files so scrolling keeps up. @@ -678,7 +702,7 @@ export function DiffPanel({ target }: { target: CenterDiffTarget }) { }); }} hunkActions={ - isWorkingCopy && !fullFileDiff && !target.unchanged + isWorkingCopy && !fullFileDiff && !target.unchanged && !ignoreWhitespace ? (hunkIndex) => (