From 6410c7b3806301ba92d2b2575f69c9c937db0537 Mon Sep 17 00:00:00 2001 From: Kersten Behrens Date: Mon, 1 Jun 2026 14:07:29 +0200 Subject: [PATCH] feature: New option to ignore casing changes. Also, the ignore casing and ignore whitespace buttons are now available in the unstaged changes file list and the commit changes in the commit details in the commit history, so you can more easily diff very large merges etc. --- src/Commands/Diff.cs | 73 +++++++++++++--- src/Resources/Locales/de_DE.axaml | 3 + src/Resources/Locales/en_US.axaml | 3 + src/Resources/Locales/es_ES.axaml | 3 + src/Resources/Locales/fr_FR.axaml | 3 + src/Resources/Locales/he_IL.axaml | 5 +- src/Resources/Locales/id_ID.axaml | 3 + src/Resources/Locales/it_IT.axaml | 3 + src/Resources/Locales/ja_JP.axaml | 3 + src/Resources/Locales/ko_KR.axaml | 3 + src/Resources/Locales/pt_BR.axaml | 3 + src/Resources/Locales/ru_RU.axaml | 3 + src/Resources/Locales/ta_IN.axaml | 3 + src/Resources/Locales/uk_UA.axaml | 3 + src/Resources/Locales/zh_CN.axaml | 3 + src/Resources/Locales/zh_TW.axaml | 3 + src/Resources/Styles.axaml | 4 + src/ViewModels/CommitDetail.cs | 134 +++++++++++++++++++++++++----- src/ViewModels/Compare.cs | 130 ++++++++++++++++++++++++----- src/ViewModels/DiffContext.cs | 27 +++++- src/ViewModels/Preferences.cs | 7 ++ src/ViewModels/RevisionCompare.cs | 130 ++++++++++++++++++++++++----- src/ViewModels/WorkingCopy.cs | 123 +++++++++++++++++++++++++-- src/Views/CommitChanges.axaml | 22 ++++- src/Views/Compare.axaml | 22 ++++- src/Views/DiffView.axaml | 7 ++ src/Views/RevisionCompare.axaml | 22 ++++- src/Views/WorkingCopy.axaml | 24 ++++-- 28 files changed, 675 insertions(+), 97 deletions(-) diff --git a/src/Commands/Diff.cs b/src/Commands/Diff.cs index 4840d14e6..4ea9a1e24 100644 --- a/src/Commands/Diff.cs +++ b/src/Commands/Diff.cs @@ -20,9 +20,10 @@ public partial class Diff : Command private const string PREFIX_LFS_DEL = "-version https://git-lfs.github.com/spec/"; private const string PREFIX_LFS_MODIFY = " version https://git-lfs.github.com/spec/"; - public Diff(string repo, Models.DiffOption opt, int unified, bool ignoreWhitespace) + public Diff(string repo, Models.DiffOption opt, int unified, bool ignoreWhitespace, bool ignoreCase = false) { _result.TextDiff = new Models.TextDiff(); + _ignoreCase = ignoreCase; WorkingDirectory = repo; Context = repo; @@ -32,7 +33,7 @@ public Diff(string repo, Models.DiffOption opt, int unified, bool ignoreWhitespa if (Models.DiffOption.IgnoreCRAtEOL) builder.Append("--ignore-cr-at-eol "); if (ignoreWhitespace) - builder.Append("--ignore-space-change "); + builder.Append("--ignore-all-space --ignore-blank-lines "); builder.Append("--unified=").Append(unified).Append(' '); builder.Append(opt.ToString()); @@ -245,14 +246,61 @@ private bool ParseLFSChange(string line) private void ProcessInlineHighlights() { - if (_deleted.Count > 0) + // Git has no `--ignore-case` option for `diff`, so case-insensitivity is + // handled here. Within a balanced block (equal number of removed/added + // lines, which is how a re-casing shows up) any pair that differs only by + // letter casing is turned into a context (unchanged) line; the remaining + // real changes keep their normal removed/added rendering. + if (_ignoreCase && _deleted.Count > 0 && _deleted.Count == _added.Count) { - if (_added.Count == _deleted.Count) + var pendingDeleted = new List(); + var pendingAdded = new List(); + + for (int i = 0; i < _deleted.Count; i++) { - for (int i = _added.Count - 1; i >= 0; i--) + var del = _deleted[i]; + var add = _added[i]; + + if (del.Content.Equals(add.Content, StringComparison.OrdinalIgnoreCase)) + { + // Emit any preceding real change first so line order is preserved. + FlushBlock(pendingDeleted, pendingAdded); + + _result.TextDiff.DeletedLines--; + _result.TextDiff.AddedLines--; + _result.TextDiff.Lines.Add(new Models.TextDiffLine( + Models.TextDiffLineType.Normal, + add.Content, + add.RawContent, + del.OldLineNumber, + add.NewLineNumber)); + } + else { - var left = _deleted[i]; - var right = _added[i]; + pendingDeleted.Add(del); + pendingAdded.Add(add); + } + } + + FlushBlock(pendingDeleted, pendingAdded); + _deleted.Clear(); + _added.Clear(); + return; + } + + FlushBlock(_deleted, _added); + } + + private void FlushBlock(List deleted, List added) + { + if (deleted.Count > 0) + { + if (added.Count == deleted.Count) + { + for (int i = added.Count - 1; i >= 0; i--) + { + var left = deleted[i]; + var right = added[i]; if (left.Content.Length > 1024 || right.Content.Length > 1024) continue; @@ -272,14 +320,14 @@ private void ProcessInlineHighlights() } } - _result.TextDiff.Lines.AddRange(_deleted); - _deleted.Clear(); + _result.TextDiff.Lines.AddRange(deleted); + deleted.Clear(); } - if (_added.Count > 0) + if (added.Count > 0) { - _result.TextDiff.Lines.AddRange(_added); - _added.Clear(); + _result.TextDiff.Lines.AddRange(added); + added.Clear(); } } @@ -289,5 +337,6 @@ private void ProcessInlineHighlights() private Models.TextDiffLine _last = null; private int _oldLine = 0; private int _newLine = 0; + private readonly bool _ignoreCase = false; } } diff --git a/src/Resources/Locales/de_DE.axaml b/src/Resources/Locales/de_DE.axaml index 95e5afb62..6912b2557 100644 --- a/src/Resources/Locales/de_DE.axaml +++ b/src/Resources/Locales/de_DE.axaml @@ -338,6 +338,9 @@ $1, $2, … Werte der Eingabe-Steuerelemente BINÄRER VERGLEICH Dateimodus geändert Erster Unterschied + Dateien mit reinen Groß-/Kleinschreibungsänderungen ausblenden + Dateien mit reinen Leerzeichen-Änderungen ausblenden + Groß-/Kleinschreibung ignorieren Ignoriere Leerzeichen-Änderungen ÜBEREINANDER DIFFERENZ diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index 88309a1c7..bfcba423b 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -361,6 +361,9 @@ BINARY DIFF File Mode Changed First Difference + Hide Files With Only Case Changes + Hide Files With Only Whitespace Changes + Ignore Case Changes Ignore Whitespace Changes BLEND DIFFERENCE diff --git a/src/Resources/Locales/es_ES.axaml b/src/Resources/Locales/es_ES.axaml index a5a7ad9d5..94d5ab4e9 100644 --- a/src/Resources/Locales/es_ES.axaml +++ b/src/Resources/Locales/es_ES.axaml @@ -363,6 +363,9 @@ DIFERENCIA BINARIA Modo de Archivo Cambiado Primera Diferencia + Ocultar Archivos con Solo Cambios de Mayúsculas/Minúsculas + Ocultar Archivos con Solo Cambios de Espacios en Blanco + Ignorar Cambios de Mayúsculas/Minúsculas Ignorar Cambio de Espacios en Blanco MEZCLAR DIFERENCIA diff --git a/src/Resources/Locales/fr_FR.axaml b/src/Resources/Locales/fr_FR.axaml index 00acfadf6..6c40e147f 100644 --- a/src/Resources/Locales/fr_FR.axaml +++ b/src/Resources/Locales/fr_FR.axaml @@ -363,6 +363,9 @@ DIFF BINAIRE Mode de fichier changé Première différence + Masquer les fichiers ne contenant que des changements de casse + Masquer les fichiers ne contenant que des changements d'espaces + Ignorer les changements de casse Ignorer les changements d'espaces FUSIONNER DIFFÉRENCE diff --git a/src/Resources/Locales/he_IL.axaml b/src/Resources/Locales/he_IL.axaml index 4d0d926e5..8d7c48fce 100644 --- a/src/Resources/Locales/he_IL.axaml +++ b/src/Resources/Locales/he_IL.axaml @@ -363,7 +363,10 @@ DIFF בינארי הרשאות הקובץ שונו ההבדל הראשון - התעלמות משינויי Whitespace + הסתרת קבצים עם שינויי רישיות בלבד + הסתרת קבצים עם שינויי רווחים בלבד + התעלמות משינויי רישיות + התעלמות משינויי רווחים מיזוג הבדל זה לצד זה diff --git a/src/Resources/Locales/id_ID.axaml b/src/Resources/Locales/id_ID.axaml index b6ff52aa5..084f4e3b8 100644 --- a/src/Resources/Locales/id_ID.axaml +++ b/src/Resources/Locales/id_ID.axaml @@ -311,6 +311,9 @@ DIFF BINARY Mode Berkas Berubah Perbedaan Pertama + Sembunyikan Berkas yang Hanya Memiliki Perubahan Huruf Besar/Kecil + Sembunyikan Berkas yang Hanya Memiliki Perubahan Whitespace + Abaikan Perubahan Huruf Besar/Kecil Abaikan Perubahan Whitespace BLEND DIFFERENCE diff --git a/src/Resources/Locales/it_IT.axaml b/src/Resources/Locales/it_IT.axaml index 404ca90fc..06047bd10 100644 --- a/src/Resources/Locales/it_IT.axaml +++ b/src/Resources/Locales/it_IT.axaml @@ -337,6 +337,9 @@ ${pure_files:N} Come ${files:N}, ma senza cartelle DIFF BINARIO Modalità File Modificata Prima differenza + Nascondi i file con sole modifiche di maiuscole/minuscole + Nascondi i file con sole modifiche agli spazi + Ignora le modifiche di maiuscole/minuscole Ignora Modifiche agli Spazi FUSIONE DIFFERENZA diff --git a/src/Resources/Locales/ja_JP.axaml b/src/Resources/Locales/ja_JP.axaml index cfce55592..0b55b2d2f 100644 --- a/src/Resources/Locales/ja_JP.axaml +++ b/src/Resources/Locales/ja_JP.axaml @@ -338,6 +338,9 @@ バイナリの差分 ファイルモードが変更されました 最初の差分 + 大文字と小文字の変更のみのファイルを非表示 + 空白文字の変更のみのファイルを非表示 + 大文字と小文字の変更を無視 空白文字の変更を無視 ブレンド 色差 diff --git a/src/Resources/Locales/ko_KR.axaml b/src/Resources/Locales/ko_KR.axaml index cbaa3b8bb..310ae5eb5 100644 --- a/src/Resources/Locales/ko_KR.axaml +++ b/src/Resources/Locales/ko_KR.axaml @@ -309,6 +309,9 @@ 바이너리 비교 파일 모드 변경됨 첫 번째 차이점 + 대소문자만 변경된 파일 숨기기 + 공백만 변경된 파일 숨기기 + 대소문자 변경 무시 공백 변경 사항 무시 혼합 차이점 diff --git a/src/Resources/Locales/pt_BR.axaml b/src/Resources/Locales/pt_BR.axaml index 59915ae84..0e825c3cb 100644 --- a/src/Resources/Locales/pt_BR.axaml +++ b/src/Resources/Locales/pt_BR.axaml @@ -234,6 +234,9 @@ Excluir dos repositórios remotos DIFERENÇA BINÁRIA Modo de Arquivo Alterado + Esconder arquivos com apenas mudanças de maiúsculas/minúsculas + Esconder arquivos com apenas mudanças de espaço em branco + Ignorar mudanças de maiúsculas/minúsculas Ignorar mudanças de espaço em branco MUDANÇA DE OBJETO LFS Próxima Diferença diff --git a/src/Resources/Locales/ru_RU.axaml b/src/Resources/Locales/ru_RU.axaml index 93310efb0..9ad676962 100644 --- a/src/Resources/Locales/ru_RU.axaml +++ b/src/Resources/Locales/ru_RU.axaml @@ -365,6 +365,9 @@ СРАВНЕНИЕ БИНАРНИКОВ Режим файла изменён Первое сравнение + Скрыть файлы, содержащие только изменения регистра + Скрыть файлы, содержащие только изменения пробелов + Игнорировать изменения регистра Игнорировать изменения пробелов СМЕСЬ СРАВНЕНИЕ diff --git a/src/Resources/Locales/ta_IN.axaml b/src/Resources/Locales/ta_IN.axaml index c67c98441..fe79ccbf1 100644 --- a/src/Resources/Locales/ta_IN.axaml +++ b/src/Resources/Locales/ta_IN.axaml @@ -217,6 +217,9 @@ இருமம் வேறுபாடு கோப்பு முறை மாற்றப்பட்டது முதல் வேறுபாடு + பெரிய/சிறிய எழுத்து மாற்றங்கள் மட்டுமே உள்ள கோப்புகளை மறை + வெள்ளைவெளி மாற்றங்கள் மட்டுமே உள்ள கோப்புகளை மறை + பெரிய/சிறிய எழுத்து மாற்றங்களைப் புறக்கணி வெள்ளைவெளி மாற்றத்தை புறக்கணி கடைசி வேறுபாடு பெகோஅ பொருள் மாற்றம் diff --git a/src/Resources/Locales/uk_UA.axaml b/src/Resources/Locales/uk_UA.axaml index ee95fc366..b0d46c052 100644 --- a/src/Resources/Locales/uk_UA.axaml +++ b/src/Resources/Locales/uk_UA.axaml @@ -221,6 +221,9 @@ РІЗНИЦЯ ДЛЯ БІНАРНИХ ФАЙЛІВ Змінено режим файлу Перша відмінність + Приховати файли, що містять лише зміни регістру + Приховати файли, що містять лише зміни пробілів + Ігнорувати зміни регістру Ігнорувати зміни пробілів Остання відмінність ЗМІНА ОБ'ЄКТА LFS diff --git a/src/Resources/Locales/zh_CN.axaml b/src/Resources/Locales/zh_CN.axaml index cd43e5d5e..b5614059e 100644 --- a/src/Resources/Locales/zh_CN.axaml +++ b/src/Resources/Locales/zh_CN.axaml @@ -365,6 +365,9 @@ 二进制文件 文件权限已变化 首个差异 + 隐藏仅有大小写变化的文件 + 隐藏仅有空白符号变化的文件 + 忽略大小写变化 忽略空白符号变化 混合对比 差异比较 diff --git a/src/Resources/Locales/zh_TW.axaml b/src/Resources/Locales/zh_TW.axaml index 8bd10a0c7..e2784f8c3 100644 --- a/src/Resources/Locales/zh_TW.axaml +++ b/src/Resources/Locales/zh_TW.axaml @@ -365,6 +365,9 @@ 二進位檔案 檔案權限已變更 第一個差異 + 隱藏僅有大小寫變化的檔案 + 隱藏僅有空白符號變化的檔案 + 忽略大小寫變化 忽略空白符號變化 混合對比 差異對比 diff --git a/src/Resources/Styles.axaml b/src/Resources/Styles.axaml index b5ac1a00a..771b1eac2 100644 --- a/src/Resources/Styles.axaml +++ b/src/Resources/Styles.axaml @@ -1300,6 +1300,10 @@ + + diff --git a/src/ViewModels/CommitDetail.cs b/src/ViewModels/CommitDetail.cs index 5d543b65c..4c8b06652 100644 --- a/src/ViewModels/CommitDetail.cs +++ b/src/ViewModels/CommitDetail.cs @@ -127,6 +127,34 @@ public string SearchChangeFilter } } + public bool HideCaseOnlyChanges + { + get => Preferences.Instance.IgnoreCaseChangesInDiff; + set + { + if (value != Preferences.Instance.IgnoreCaseChangesInDiff) + { + Preferences.Instance.IgnoreCaseChangesInDiff = value; + OnPropertyChanged(); + RecomputeHidden(); + } + } + } + + public bool HideWhitespaceOnlyChanges + { + get => Preferences.Instance.IgnoreWhitespaceChangesInDiff; + set + { + if (value != Preferences.Instance.IgnoreWhitespaceChangesInDiff) + { + Preferences.Instance.IgnoreWhitespaceChangesInDiff = value; + OnPropertyChanged(); + RecomputeHidden(); + } + } + } + public string ViewRevisionFilePath { get => _viewRevisionFilePath; @@ -471,6 +499,7 @@ private void Refresh() ViewRevisionFilePath = string.Empty; CanOpenRevisionFileWithDefaultEditor = false; Children = null; + _hiddenChanges = []; RevisionFileSearchFilter = string.Empty; RevisionFileSearchSuggestion = null; ScrollOffset = Vector.Zero; @@ -533,25 +562,17 @@ private void Refresh() { var cmd = new Commands.CompareRevisions(_repo.FullPath, _commit.FirstParentToCompare, _commit.SHA) { CancellationToken = token }; var changes = await cmd.ReadAsync().ConfigureAwait(false); - var visible = changes; - if (!string.IsNullOrWhiteSpace(_searchChangeFilter)) - { - visible = new List(); - foreach (var c in changes) - { - if (c.Path.Contains(_searchChangeFilter, StringComparison.OrdinalIgnoreCase)) - visible.Add(c); - } - } + var hidden = await ComputeHiddenAsync(changes, token).ConfigureAwait(false); if (!token.IsCancellationRequested) { Dispatcher.UIThread.Post(() => { + _hiddenChanges = hidden; Changes = changes; - VisibleChanges = visible; + VisibleChanges = ApplyFilters(changes); - if (visible.Count == 0) + if (VisibleChanges.Count == 0) SelectedChanges = null; else SelectedChanges = [VisibleChanges[0]]; @@ -613,21 +634,90 @@ private void Refresh() private void RefreshVisibleChanges() { - if (string.IsNullOrEmpty(_searchChangeFilter)) + VisibleChanges = ApplyFilters(_changes); + } + + private List ApplyFilters(List source) + { + var hideEmpty = Preferences.Instance.IgnoreCaseChangesInDiff || + Preferences.Instance.IgnoreWhitespaceChangesInDiff; + + if (string.IsNullOrEmpty(_searchChangeFilter) && !hideEmpty) + return source; + + var visible = new List(); + foreach (var c in source) + { + if (!string.IsNullOrEmpty(_searchChangeFilter) && !c.Path.Contains(_searchChangeFilter, StringComparison.OrdinalIgnoreCase)) + continue; + + if (hideEmpty && _hiddenChanges.Contains(c.Path)) + continue; + + visible.Add(c); + } + + return visible; + } + + private void RecomputeHidden() + { + var gen = ++_hiddenToken; + + if (!Preferences.Instance.IgnoreCaseChangesInDiff && !Preferences.Instance.IgnoreWhitespaceChangesInDiff) { - VisibleChanges = _changes; + _hiddenChanges = []; + RefreshVisibleChanges(); + return; } - else + + var changes = _changes; + Task.Run(async () => { - var visible = new List(); - foreach (var c in _changes) + var hidden = await ComputeHiddenAsync(changes, CancellationToken.None).ConfigureAwait(false); + Dispatcher.UIThread.Post(() => { - if (c.Path.Contains(_searchChangeFilter, StringComparison.OrdinalIgnoreCase)) - visible.Add(c); - } + if (gen != _hiddenToken) + return; + + _hiddenChanges = hidden; + RefreshVisibleChanges(); + }); + }); + } + + private async Task> ComputeHiddenAsync(List changes, CancellationToken token) + { + var set = new HashSet(); + if (changes == null || _commit == null) + return set; + + var ignoreWhitespace = Preferences.Instance.IgnoreWhitespaceChangesInDiff; + var ignoreCase = Preferences.Instance.IgnoreCaseChangesInDiff; + if (!ignoreWhitespace && !ignoreCase) + return set; + + foreach (var c in changes) + { + if (token.IsCancellationRequested) + break; + + // Only plain content modifications can be whitespace/case-only noise; never hide + // additions, deletions, renames or copies. + if (c.Index != Models.ChangeState.Modified) + continue; - VisibleChanges = visible; + var opt = new Models.DiffOption(_commit, c); + var rs = await new Commands.Diff(_repo.FullPath, opt, 0, ignoreWhitespace, ignoreCase) { CancellationToken = token } + .ReadAsync() + .ConfigureAwait(false); + + if (!rs.IsBinary && !rs.IsLFS && + (rs.TextDiff == null || (rs.TextDiff.AddedLines == 0 && rs.TextDiff.DeletedLines == 0))) + set.Add(c.Path); } + + return set; } private void RefreshRevisionSearchSuggestion() @@ -767,6 +857,8 @@ private async Task SetViewingCommitAsync(Models.Object file) private List _changes = []; private List _visibleChanges = []; private List _selectedChanges = null; + private HashSet _hiddenChanges = []; + private int _hiddenToken = 0; private string _searchChangeFilter = string.Empty; private DiffContext _diffContext = null; private string _viewRevisionFilePath = string.Empty; diff --git a/src/ViewModels/Compare.cs b/src/ViewModels/Compare.cs index 9a3f4f490..6cf25d7c1 100644 --- a/src/ViewModels/Compare.cs +++ b/src/ViewModels/Compare.cs @@ -93,6 +93,34 @@ public string SearchFilter } } + public bool HideCaseOnlyChanges + { + get => Preferences.Instance.IgnoreCaseChangesInDiff; + set + { + if (value != Preferences.Instance.IgnoreCaseChangesInDiff) + { + Preferences.Instance.IgnoreCaseChangesInDiff = value; + OnPropertyChanged(); + RecomputeHidden(); + } + } + } + + public bool HideWhitespaceOnlyChanges + { + get => Preferences.Instance.IgnoreWhitespaceChangesInDiff; + set + { + if (value != Preferences.Instance.IgnoreWhitespaceChangesInDiff) + { + Preferences.Instance.IgnoreWhitespaceChangesInDiff = value; + OnPropertyChanged(); + RecomputeHidden(); + } + } + } + public DiffContext DiffContext { get => _diffContext; @@ -311,27 +339,24 @@ private void UpdateChanges() VisibleChanges = []; SelectedChanges = []; + var token = ++_caseFilterToken; + Task.Run(async () => { _changes = await new Commands.CompareRevisions(_repo.FullPath, _based, _to) .ReadAsync() .ConfigureAwait(false); - var visible = _changes; - if (!string.IsNullOrWhiteSpace(_searchFilter)) - { - visible = new List(); - foreach (var c in _changes) - { - if (c.Path.Contains(_searchFilter, StringComparison.OrdinalIgnoreCase)) - visible.Add(c); - } - } + var hidden = await ComputeHiddenAsync(_changes).ConfigureAwait(false); Dispatcher.UIThread.Post(() => { + if (token != _caseFilterToken) + return; + + _hidden = hidden; TotalChanges = _changes.Count; - VisibleChanges = visible; + VisibleChanges = ApplyFilters(_changes); IsLoadingChanges = false; if (VisibleChanges.Count > 0) @@ -342,26 +367,89 @@ private void UpdateChanges() }); } + private List ApplyFilters(List source) + { + var hideEmpty = Preferences.Instance.IgnoreCaseChangesInDiff || + Preferences.Instance.IgnoreWhitespaceChangesInDiff; + if (string.IsNullOrEmpty(_searchFilter) && !hideEmpty) + return source; + + var visible = new List(); + foreach (var c in source) + { + if (!string.IsNullOrEmpty(_searchFilter) && !c.Path.Contains(_searchFilter, StringComparison.OrdinalIgnoreCase)) + continue; + + if (hideEmpty && _hidden.Contains(c.Path)) + continue; + + visible.Add(c); + } + + return visible; + } + private void RefreshVisibleChanges() { if (_changes == null) return; - if (string.IsNullOrEmpty(_searchFilter)) + VisibleChanges = ApplyFilters(_changes); + } + + private void RecomputeHidden() + { + var token = ++_caseFilterToken; + + if (!Preferences.Instance.IgnoreCaseChangesInDiff && !Preferences.Instance.IgnoreWhitespaceChangesInDiff) { - VisibleChanges = _changes; + _hidden = []; + RefreshVisibleChanges(); + return; } - else + + var changes = _changes; + Task.Run(async () => { - var visible = new List(); - foreach (var c in _changes) + var set = await ComputeHiddenAsync(changes).ConfigureAwait(false); + Dispatcher.UIThread.Post(() => { - if (c.Path.Contains(_searchFilter, StringComparison.OrdinalIgnoreCase)) - visible.Add(c); - } + if (token != _caseFilterToken) + return; + + _hidden = set; + RefreshVisibleChanges(); + }); + }); + } - VisibleChanges = visible; + private async Task> ComputeHiddenAsync(List changes) + { + var set = new HashSet(); + var ignoreWhitespace = Preferences.Instance.IgnoreWhitespaceChangesInDiff; + var ignoreCase = Preferences.Instance.IgnoreCaseChangesInDiff; + if (changes == null || (!ignoreWhitespace && !ignoreCase)) + return set; + + var repo = _repo.FullPath; + var based = _based; + var to = _to; + + foreach (var c in changes) + { + // Only plain content modifications can be whitespace/case-only noise; never hide + // additions, deletions, renames or copies. + if (c.Index != Models.ChangeState.Modified) + continue; + + var opt = new Models.DiffOption(based, to, c); + var rs = await new Commands.Diff(repo, opt, 0, ignoreWhitespace, ignoreCase).ReadAsync().ConfigureAwait(false); + if (!rs.IsBinary && !rs.IsLFS && + (rs.TextDiff == null || (rs.TextDiff.AddedLines == 0 && rs.TextDiff.DeletedLines == 0))) + set.Add(c.Path); } + + return set; } private string GetName(object obj) @@ -401,6 +489,8 @@ private string GetSHA(object obj) private List _changes = null; private List _visibleChanges = null; private List _selectedChanges = null; + private HashSet _hidden = []; + private int _caseFilterToken = 0; private List _leftOnlyCommits = []; private List _rightOnlyCommits = []; private string _searchFilter = string.Empty; diff --git a/src/ViewModels/DiffContext.cs b/src/ViewModels/DiffContext.cs index 25155771c..1e380c54c 100644 --- a/src/ViewModels/DiffContext.cs +++ b/src/ViewModels/DiffContext.cs @@ -27,6 +27,20 @@ public bool IgnoreWhitespace } } + public bool IgnoreCase + { + get => Preferences.Instance.IgnoreCaseChangesInDiff; + set + { + if (value != Preferences.Instance.IgnoreCaseChangesInDiff) + { + Preferences.Instance.IgnoreCaseChangesInDiff = value; + OnPropertyChanged(); + LoadContent(); + } + } + } + public bool ShowEntireFile { get => Preferences.Instance.UseFullTextDiff; @@ -128,7 +142,8 @@ public void CheckSettings() { if ((ShowEntireFile && _info.UnifiedLines != _entireFileLine) || (!ShowEntireFile && _info.UnifiedLines == _entireFileLine) || - (IgnoreWhitespace != _info.IgnoreWhitespace)) + (IgnoreWhitespace != _info.IgnoreWhitespace) || + (IgnoreCase != _info.IgnoreCase)) { LoadContent(); return; @@ -152,12 +167,13 @@ private void LoadContent() { var numLines = Preferences.Instance.UseFullTextDiff ? _entireFileLine : _unifiedLines; var ignoreWhitespace = Preferences.Instance.IgnoreWhitespaceChangesInDiff; + var ignoreCase = Preferences.Instance.IgnoreCaseChangesInDiff; - var latest = await new Commands.Diff(_repo, _option, numLines, ignoreWhitespace) + var latest = await new Commands.Diff(_repo, _option, numLines, ignoreWhitespace, ignoreCase) .ReadAsync() .ConfigureAwait(false); - var info = new Info(_option, numLines, ignoreWhitespace, latest); + var info = new Info(_option, numLines, ignoreWhitespace, ignoreCase, latest); if (_info != null && info.IsSame(_info)) return; @@ -319,14 +335,16 @@ private class Info public string Argument { get; } public int UnifiedLines { get; } public bool IgnoreWhitespace { get; } + public bool IgnoreCase { get; } public string OldHash { get; } public string NewHash { get; } - public Info(Models.DiffOption option, int unifiedLines, bool ignoreWhitespace, Models.DiffResult result) + public Info(Models.DiffOption option, int unifiedLines, bool ignoreWhitespace, bool ignoreCase, Models.DiffResult result) { Argument = option.ToString(); UnifiedLines = unifiedLines; IgnoreWhitespace = ignoreWhitespace; + IgnoreCase = ignoreCase; OldHash = result.OldHash; NewHash = result.NewHash; } @@ -336,6 +354,7 @@ public bool IsSame(Info other) return Argument.Equals(other.Argument, StringComparison.Ordinal) && UnifiedLines == other.UnifiedLines && IgnoreWhitespace == other.IgnoreWhitespace && + IgnoreCase == other.IgnoreCase && OldHash.Equals(other.OldHash, StringComparison.Ordinal) && NewHash.Equals(other.NewHash, StringComparison.Ordinal); } diff --git a/src/ViewModels/Preferences.cs b/src/ViewModels/Preferences.cs index 318b39551..625921791 100644 --- a/src/ViewModels/Preferences.cs +++ b/src/ViewModels/Preferences.cs @@ -284,6 +284,12 @@ public bool IgnoreWhitespaceChangesInDiff set => SetProperty(ref _ignoreWhitespaceChangesInDiff, value); } + public bool IgnoreCaseChangesInDiff + { + get => _ignoreCaseChangesInDiff; + set => SetProperty(ref _ignoreCaseChangesInDiff, value); + } + public bool EnableDiffViewWordWrap { get => _enableDiffViewWordWrap; @@ -844,6 +850,7 @@ private bool RemoveInvalidRepositoriesRecursive(List collection) private bool _displayTimeAsPeriodInHistories = false; private bool _useSideBySideDiff = false; private bool _ignoreWhitespaceChangesInDiff = false; + private bool _ignoreCaseChangesInDiff = false; private bool _useSyntaxHighlighting = false; private bool _enableDiffViewWordWrap = false; private bool _showHiddenSymbolsInDiffView = false; diff --git a/src/ViewModels/RevisionCompare.cs b/src/ViewModels/RevisionCompare.cs index ef38668cc..ffaa0bc6d 100644 --- a/src/ViewModels/RevisionCompare.cs +++ b/src/ViewModels/RevisionCompare.cs @@ -95,6 +95,34 @@ public string SearchFilter } } + public bool HideCaseOnlyChanges + { + get => Preferences.Instance.IgnoreCaseChangesInDiff; + set + { + if (value != Preferences.Instance.IgnoreCaseChangesInDiff) + { + Preferences.Instance.IgnoreCaseChangesInDiff = value; + OnPropertyChanged(); + RecomputeHidden(); + } + } + } + + public bool HideWhitespaceOnlyChanges + { + get => Preferences.Instance.IgnoreWhitespaceChangesInDiff; + set + { + if (value != Preferences.Instance.IgnoreWhitespaceChangesInDiff) + { + Preferences.Instance.IgnoreWhitespaceChangesInDiff = value; + OnPropertyChanged(); + RecomputeHidden(); + } + } + } + public DiffContext DiffContext { get => _diffContext; @@ -324,51 +352,111 @@ public void ClearSearchFilter() SearchFilter = string.Empty; } + private List ApplyFilters(List source) + { + var hideEmpty = Preferences.Instance.IgnoreCaseChangesInDiff || + Preferences.Instance.IgnoreWhitespaceChangesInDiff; + if (string.IsNullOrEmpty(_searchFilter) && !hideEmpty) + return source; + + var visible = new List(); + foreach (var c in source) + { + if (!string.IsNullOrEmpty(_searchFilter) && !c.Path.Contains(_searchFilter, StringComparison.OrdinalIgnoreCase)) + continue; + + if (hideEmpty && _hidden.Contains(c.Path)) + continue; + + visible.Add(c); + } + + return visible; + } + private void RefreshVisible() { if (_changes == null) return; - if (string.IsNullOrEmpty(_searchFilter)) + VisibleChanges = ApplyFilters(_changes); + } + + private void RecomputeHidden() + { + var token = ++_caseFilterToken; + + if (!Preferences.Instance.IgnoreCaseChangesInDiff && !Preferences.Instance.IgnoreWhitespaceChangesInDiff) { - VisibleChanges = _changes; + _hidden = []; + RefreshVisible(); + return; } - else + + var changes = _changes; + Task.Run(async () => { - var visible = new List(); - foreach (var c in _changes) + var set = await ComputeHiddenAsync(changes).ConfigureAwait(false); + Dispatcher.UIThread.Post(() => { - if (c.Path.Contains(_searchFilter, StringComparison.OrdinalIgnoreCase)) - visible.Add(c); - } + if (token != _caseFilterToken) + return; + + _hidden = set; + RefreshVisible(); + }); + }); + } + + private async Task> ComputeHiddenAsync(List changes) + { + var set = new HashSet(); + var ignoreWhitespace = Preferences.Instance.IgnoreWhitespaceChangesInDiff; + var ignoreCase = Preferences.Instance.IgnoreCaseChangesInDiff; + if (changes == null || (!ignoreWhitespace && !ignoreCase)) + return set; + + var repo = _repo.FullPath; + var baseSHA = GetSHA(_startPoint); + var toSHA = GetSHA(_endPoint); - VisibleChanges = visible; + foreach (var c in changes) + { + // Only plain content modifications can be whitespace/case-only noise; never hide + // additions, deletions, renames or copies. + if (c.Index != Models.ChangeState.Modified) + continue; + + var opt = new Models.DiffOption(baseSHA, toSHA, c); + var rs = await new Commands.Diff(repo, opt, 0, ignoreWhitespace, ignoreCase).ReadAsync().ConfigureAwait(false); + if (!rs.IsBinary && !rs.IsLFS && + (rs.TextDiff == null || (rs.TextDiff.AddedLines == 0 && rs.TextDiff.DeletedLines == 0))) + set.Add(c.Path); } + + return set; } private void Refresh() { + var token = ++_caseFilterToken; + Task.Run(async () => { _changes = await new Commands.CompareRevisions(_repo.FullPath, GetSHA(_startPoint), GetSHA(_endPoint)) .ReadAsync() .ConfigureAwait(false); - var visible = _changes; - if (!string.IsNullOrWhiteSpace(_searchFilter)) - { - visible = []; - foreach (var c in _changes) - { - if (c.Path.Contains(_searchFilter, StringComparison.OrdinalIgnoreCase)) - visible.Add(c); - } - } + var hidden = await ComputeHiddenAsync(_changes).ConfigureAwait(false); Dispatcher.UIThread.Post(() => { + if (token != _caseFilterToken) + return; + + _hidden = hidden; TotalChanges = _changes.Count; - VisibleChanges = visible; + VisibleChanges = ApplyFilters(_changes); IsLoading = false; if (VisibleChanges.Count > 0) @@ -397,6 +485,8 @@ private string GetDesc(object obj) private List _changes = null; private List _visibleChanges = null; private List _selectedChanges = null; + private HashSet _hidden = []; + private int _caseFilterToken = 0; private string _searchFilter = string.Empty; private DiffContext _diffContext = null; } diff --git a/src/ViewModels/WorkingCopy.cs b/src/ViewModels/WorkingCopy.cs index 930b37585..d6c87738d 100644 --- a/src/ViewModels/WorkingCopy.cs +++ b/src/ViewModels/WorkingCopy.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Threading.Tasks; +using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; namespace SourceGit.ViewModels @@ -107,7 +108,7 @@ public bool UseAmend } Staged = GetStagedChanges(_cached); - VisibleStaged = GetVisibleChanges(_staged); + VisibleStaged = GetVisibleChanges(_staged, false); SelectedStaged = []; } } @@ -129,13 +130,41 @@ public string Filter if (_isLoadingData) return; - VisibleUnstaged = GetVisibleChanges(_unstaged); - VisibleStaged = GetVisibleChanges(_staged); + VisibleUnstaged = GetVisibleChanges(_unstaged, true); + VisibleStaged = GetVisibleChanges(_staged, false); SelectedUnstaged = []; } } } + public bool HideCaseOnlyChanges + { + get => Preferences.Instance.IgnoreCaseChangesInDiff; + set + { + if (value != Preferences.Instance.IgnoreCaseChangesInDiff) + { + Preferences.Instance.IgnoreCaseChangesInDiff = value; + OnPropertyChanged(); + RefreshHiddenFilter(); + } + } + } + + public bool HideWhitespaceOnlyChanges + { + get => Preferences.Instance.IgnoreWhitespaceChangesInDiff; + set + { + if (value != Preferences.Instance.IgnoreWhitespaceChangesInDiff) + { + Preferences.Instance.IgnoreWhitespaceChangesInDiff = value; + OnPropertyChanged(); + RefreshHiddenFilter(); + } + } + } + public List Unstaged { get => _unstaged; @@ -277,7 +306,7 @@ public void SetData(List changes) } var staged = GetStagedChanges(changes); - var visibleStaged = GetVisibleChanges(staged); + var visibleStaged = GetVisibleChanges(staged, false); var selectedStaged = new List(); if (_selectedStaged is { Count: > 0 }) { @@ -312,6 +341,9 @@ public void SetData(List changes) UpdateInProgressState(); UpdateDetail(); + + if (Preferences.Instance.IgnoreCaseChangesInDiff || Preferences.Instance.IgnoreWhitespaceChangesInDiff) + RefreshHiddenFilter(); } public async Task StageChangesAsync(List changes, Models.Change next) @@ -662,22 +694,94 @@ public async Task CommitAsync(bool autoStage, bool autoPush) IsCommitting = false; } - private List GetVisibleChanges(List changes) + private List GetVisibleChanges(List changes, bool isUnstaged) { - if (string.IsNullOrEmpty(_filter)) + var hideEmpty = Preferences.Instance.IgnoreCaseChangesInDiff || + Preferences.Instance.IgnoreWhitespaceChangesInDiff; + var hidden = isUnstaged ? _hiddenUnstaged : _hiddenStaged; + + if (string.IsNullOrEmpty(_filter) && !hideEmpty) return changes; var visible = new List(); foreach (var c in changes) { - if (c.Path.Contains(_filter, StringComparison.OrdinalIgnoreCase)) - visible.Add(c); + if (!string.IsNullOrEmpty(_filter) && !c.Path.Contains(_filter, StringComparison.OrdinalIgnoreCase)) + continue; + + if (hideEmpty && hidden.Contains(c.Path)) + continue; + + visible.Add(c); } return visible; } + private void RefreshHiddenFilter() + { + var token = ++_caseFilterToken; + + var ignoreCase = Preferences.Instance.IgnoreCaseChangesInDiff; + var ignoreWhitespace = Preferences.Instance.IgnoreWhitespaceChangesInDiff; + if (!ignoreCase && !ignoreWhitespace) + { + _hiddenUnstaged = []; + _hiddenStaged = []; + VisibleUnstaged = GetVisibleChanges(_unstaged, true); + VisibleStaged = GetVisibleChanges(_staged, false); + return; + } + + var repo = _repo.FullPath; + var unstaged = _unstaged; + var staged = _staged; + + Task.Run(async () => + { + var hiddenUnstaged = new HashSet(); + foreach (var c in unstaged) + { + if (await IsHiddenChangeAsync(repo, c, true, ignoreWhitespace, ignoreCase).ConfigureAwait(false)) + hiddenUnstaged.Add(c.Path); + } + + var hiddenStaged = new HashSet(); + foreach (var c in staged) + { + if (await IsHiddenChangeAsync(repo, c, false, ignoreWhitespace, ignoreCase).ConfigureAwait(false)) + hiddenStaged.Add(c.Path); + } + + Dispatcher.UIThread.Post(() => + { + if (token != _caseFilterToken) + return; + + _hiddenUnstaged = hiddenUnstaged; + _hiddenStaged = hiddenStaged; + VisibleUnstaged = GetVisibleChanges(_unstaged, true); + VisibleStaged = GetVisibleChanges(_staged, false); + }); + }); + } + + private static async Task IsHiddenChangeAsync(string repo, Models.Change c, bool isUnstaged, bool ignoreWhitespace, bool ignoreCase) + { + // Only plain content modifications can be whitespace/case-only noise; never hide + // additions, deletions, renames, untracked or conflicted entries. + var state = isUnstaged ? c.WorkTree : c.Index; + if (state != Models.ChangeState.Modified) + return false; + + var rs = await new Commands.Diff(repo, new Models.DiffOption(c, isUnstaged), 0, ignoreWhitespace, ignoreCase) + .ReadAsync() + .ConfigureAwait(false); + return !rs.IsBinary && !rs.IsLFS && + (rs.TextDiff == null || (rs.TextDiff.AddedLines == 0 && rs.TextDiff.DeletedLines == 0)); + } + private async Task> GetCanStageChangesAsync(List changes) { if (!HasUnsolvedConflicts) @@ -816,6 +920,9 @@ private bool IsChanged(List old, List cur) private List _visibleStaged = []; private List _selectedUnstaged = []; private List _selectedStaged = []; + private HashSet _hiddenUnstaged = []; + private HashSet _hiddenStaged = []; + private int _caseFilterToken = 0; private object _detailContext = null; private string _filter = string.Empty; private string _commitMessage = string.Empty; diff --git a/src/Views/CommitChanges.axaml b/src/Views/CommitChanges.axaml index d7d245ccf..10fabc43c 100644 --- a/src/Views/CommitChanges.axaml +++ b/src/Views/CommitChanges.axaml @@ -16,7 +16,7 @@ - + - + + + + + + + + diff --git a/src/Views/Compare.axaml b/src/Views/Compare.axaml index e861b22d0..19931cb5e 100644 --- a/src/Views/Compare.axaml +++ b/src/Views/Compare.axaml @@ -138,7 +138,7 @@ - + - + + + + + + + + diff --git a/src/Views/DiffView.axaml b/src/Views/DiffView.axaml index ae79c4dc9..3c52df9e4 100644 --- a/src/Views/DiffView.axaml +++ b/src/Views/DiffView.axaml @@ -186,6 +186,13 @@ + + + + - + - + + + + + + + + diff --git a/src/Views/WorkingCopy.axaml b/src/Views/WorkingCopy.axaml index a3bf6fdf7..48532ba21 100644 --- a/src/Views/WorkingCopy.axaml +++ b/src/Views/WorkingCopy.axaml @@ -61,7 +61,7 @@ - + @@ -78,11 +78,25 @@ + + + + + + - - - -