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 @@ + + + + + + - - - -