From 9ecd15283dae253dbbf0fc3f4ea57b23f55a117d Mon Sep 17 00:00:00 2001 From: lixiongyou Date: Mon, 26 Oct 2026 17:53:45 +0800 Subject: [PATCH 1/3] feature: inspect changes across selected commits Aggregate selected changes with immutable endpoints and retain the Information, Changes, and Files views. Reuse complete commit information in virtualized cards and keep explicit revision comparisons available. Add read-only Git and view-model regression coverage and document aggregation semantics and limitations. --- docs/selected-commit-changes.md | 106 +++ src/Commands/Diff.cs | 13 + src/Commands/QuerySelectedCommitChanges.cs | 180 +++++ src/Models/CommitChange.cs | 91 +++ src/Models/DiffOption.cs | 56 ++ src/Resources/Locales/de_DE.axaml | 4 + src/Resources/Locales/el_GR.axaml | 4 + src/Resources/Locales/en_US.axaml | 4 + src/Resources/Locales/es_ES.axaml | 4 + src/Resources/Locales/fr_FR.axaml | 4 + src/Resources/Locales/he_IL.axaml | 4 + src/Resources/Locales/id_ID.axaml | 4 + src/Resources/Locales/it_IT.axaml | 4 + src/Resources/Locales/ja_JP.axaml | 4 + src/Resources/Locales/ko_KR.axaml | 4 + src/Resources/Locales/pt_BR.axaml | 4 + src/Resources/Locales/ru_RU.axaml | 4 + src/Resources/Locales/ta_IN.axaml | 4 + src/Resources/Locales/uk_UA.axaml | 4 + src/Resources/Locales/zh_CN.axaml | 4 + src/Resources/Locales/zh_TW.axaml | 4 + src/ViewModels/CommitChanges.cs | 217 ++++++ src/ViewModels/CommitDetail.cs | 142 +++- src/ViewModels/DiffContext.cs | 39 +- src/ViewModels/Histories.cs | 47 +- src/ViewModels/Repository.cs | 2 + src/Views/CommitBaseInfo.axaml.cs | 4 +- src/Views/CommitDetail.axaml | 60 +- src/Views/CommitDetail.axaml.cs | 317 +------- src/Views/CommitInformation.axaml | 79 ++ src/Views/CommitInformation.axaml.cs | 400 ++++++++++ src/Views/Histories.axaml | 80 +- src/Views/Histories.axaml.cs | 17 + src/Views/RevisionFileTreeView.axaml.cs | 70 +- src/Views/SelectedCommitChanges.axaml | 130 ++++ src/Views/SelectedCommitChanges.axaml.cs | 91 +++ tests/SelectedCommitChanges/Program.cs | 732 ++++++++++++++++++ tests/SelectedCommitChanges/README.md | 40 + .../SelectedCommitChanges.csproj | 17 + tests/SelectedCommitChanges/Stubs.cs | 145 ++++ 40 files changed, 2672 insertions(+), 467 deletions(-) create mode 100644 docs/selected-commit-changes.md create mode 100644 src/Commands/QuerySelectedCommitChanges.cs create mode 100644 src/Models/CommitChange.cs create mode 100644 src/ViewModels/CommitChanges.cs create mode 100644 src/Views/CommitInformation.axaml create mode 100644 src/Views/CommitInformation.axaml.cs create mode 100644 src/Views/SelectedCommitChanges.axaml create mode 100644 src/Views/SelectedCommitChanges.axaml.cs create mode 100644 tests/SelectedCommitChanges/Program.cs create mode 100644 tests/SelectedCommitChanges/README.md create mode 100644 tests/SelectedCommitChanges/SelectedCommitChanges.csproj create mode 100644 tests/SelectedCommitChanges/Stubs.cs diff --git a/docs/selected-commit-changes.md b/docs/selected-commit-changes.md new file mode 100644 index 0000000000..9a2a44ad50 --- /dev/null +++ b/docs/selected-commit-changes.md @@ -0,0 +1,106 @@ +# Selected-commit change summary + +Select two or more commits in History (Ctrl/Cmd-click for individual commits, +Shift-click for a range). The Changes tab shows their joined file changes and +loads the selected file's diff. All three detail tabs remain available: + +* **Information** lists exactly the selected commits in displayed history order, + in bordered cards using the same full information view as a single commit: + metadata, full message, signature, links and changed-file preview. Cards load + information on demand with bounded concurrency; offscreen cards are virtualized. +* **Changes** keeps the joined summary for the entire history selection. +* **Files** reuses the single-commit historical file browser: full tree, search, + previews, open/save and applicable existing file actions. It defaults to the + topmost selected history commit, not a synthetic combined tree. Choose a card + in Information or use the SHA/subject dropdown in Files to browse another + selected revision; this never changes the outer history selection or summary. + +The file browser is loaded lazily, and changing its revision replaces/disposes +the old detail context. Late tree/search/preview results cannot overwrite a +newer revision or file selection. Search also matches the original name of a +renamed file. Selecting one commit restores the existing commit detail view. + +For the previous two-endpoint comparison, select exactly two commits and choose +**Compare these two revisions (not selected changes)** from the context menu. +This is intentionally distinct from the selected-commit summary. + +## What is aggregated + +The implementation follows the path-based “zip changes” model used by Rebased: + +* Process only selected commits, in oldest-to-newest displayed history order. + Clicking the same commits in a different order does not reverse their changes. +* Join a later change to the earliest existing change whose destination is the + later change's source path. Keep the first before endpoint and last after + endpoint. Rename chains therefore retain their initial and final names. +* Add then delete cancels. Delete then add remains two independent entries. + Reverting to identical content keeps an entry, whose content diff is empty. +* A merge contributes only paths changed relative to **every** parent. Diff + content uses the first parent's real before endpoint. Different-source merge + renames are displayed as modifications while retaining a valid before source. + +This is **not** a diff of the oldest parent's whole tree against the newest +commit, and it does **not** replay/cherry-pick patches into a synthetic tree. +Files changed only by skipped commits do not appear. When selected commits touch +the same file, their before/after comparison can include intervening unselected +edits to that file. Path-based joining across unrelated branches has the same +limitation; the result is not a hypothetical merge or cherry-pick result. + +Rebased can additionally present specialized per-parent/three-way merge views. +This feature provides SourceGit's existing two-pane/unified diff presentation, +not a new three-way merge viewer. + +## Safety and lifecycle + +All summary queries are read-only Git commands. They do not change HEAD, refs, +the index or working files, and do not create temporary commits or objects. +Git output is NUL-delimited, preserving tabs, newlines, quotes and Unicode in +paths. Exact blob endpoints prevent unrelated occupants of old/new renamed paths +from appearing in a file diff. Absent endpoints never mean the working copy. + +Changing selection or closing the repository cancels pending summary queries. +Disposed view models reject queued success/error callbacks. Filtering uses the +latest UI value after loading, and hiding a selected file clears/replaces its +content rather than keeping stale content on screen. Diff refreshes also use a +generation guard for repeated context/whitespace changes. + +Summary context menus intentionally offer only external diff and copy path; +reset/check-out and aggregate patch export are not exposed because selected +changes do not share one pair of tree revisions. External diff is disabled for +Gitlinks (submodules); their internal historical revision display remains +available. + +## Verification + +Run the framework-free production-code regression harness: + +```sh +dotnet run --project tests/SelectedCommitChanges/SelectedCommitChanges.csproj +``` + +Build the app (including compiled Avalonia bindings): + +```sh +dotnet build src/SourceGit.csproj -c Debug -m:1 -p:UseSharedCompilation=false +``` + +Run the upstream formatting check: + +```sh +dotnet format --verify-no-changes src/SourceGit.csproj +``` + +The harness exercises production query and aggregate view-model code, but stubs +Avalonia dispatch and individual commit details. It does not replace runtime UI +checks. Manually check Ctrl/Cmd and Shift selection, each detail tab, repeated +card/revision navigation, search, file actions, and selection changes while data +is loading. This feature adds no runtime NuGet dependencies. + +## Known rendering edge case + +Same-path endpoints retain Git's path-aware diff and `.gitattributes` behavior. +Renamed endpoints use exact blobs so unrelated files occupying either name cannot +leak into the diff. That blob comparison does not apply filename-based attribute +diff drivers/classification; for example an ASCII file explicitly marked `-diff` +may appear as text after a rename. Ordinary NUL-detected binary files remain +binary. This limitation does not change which selected paths are aggregated. diff --git a/src/Commands/Diff.cs b/src/Commands/Diff.cs index fb008aeee9..1680dcf588 100644 --- a/src/Commands/Diff.cs +++ b/src/Commands/Diff.cs @@ -41,6 +41,15 @@ public partial class Diff : Command public Diff(string repo, Models.DiffOption opt, int numContextLines, bool ignoreWhitespace, bool ignoreCRAtEOL) { _result.TextDiff = new Models.TextDiff(); + if (opt.CommitChange is { } joined && + (string.IsNullOrEmpty(joined.BeforeObject) || joined.BeforeMode == "160000") && + (string.IsNullOrEmpty(joined.AfterObject) || joined.AfterMode == "160000")) + { + _result.IsSubmoduleChange = true; + _result.OldHash = joined.BeforeObject; + _result.NewHash = joined.AfterObject; + _result.TextDiff = null; + } WorkingDirectory = repo; Context = repo; @@ -59,6 +68,10 @@ public Diff(string repo, Models.DiffOption opt, int numContextLines, bool ignore public async Task ReadAsync() { + // Gitlink targets need not exist in the superproject object database. + if (_result.IsSubmoduleChange) + return _result; + try { using var proc = new Process(); diff --git a/src/Commands/QuerySelectedCommitChanges.cs b/src/Commands/QuerySelectedCommitChanges.cs new file mode 100644 index 0000000000..b6e171a9db --- /dev/null +++ b/src/Commands/QuerySelectedCommitChanges.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + public class QuerySelectedCommitChanges : Command + { + public QuerySelectedCommitChanges(string repo) + { + WorkingDirectory = repo; + Context = repo; + Editor = EditorType.None; + } + + /// Selected commits in history order, newest first. + public async Task> ReadAsync(IReadOnlyList commits, CancellationToken cancellationToken) + { + var changes = new List(); + for (var i = commits.Count - 1; i >= 0; i--) + { + cancellationToken.ThrowIfCancellationRequested(); + var commit = commits[i]; + var parentChanges = new List>(); + if (commit.Parents.Count == 0) + { + // --root works without writing an empty tree into the object database. + Args = $"diff-tree --root --no-commit-id --raw -z -r -M --no-ext-diff --ignore-submodules=none --no-abbrev {commit.SHA}"; + var output = await ReadRawAsync(cancellationToken).ConfigureAwait(false); + parentChanges.Add(Parse(output, commit.FirstParentToCompare, commit.SHA)); + } + else + { + foreach (var parent in commit.Parents) + { + cancellationToken.ThrowIfCancellationRequested(); + Args = $"diff-tree --no-commit-id --raw -z -r -M --no-ext-diff --ignore-submodules=none --no-abbrev {parent} {commit.SHA}"; + var output = await ReadRawAsync(cancellationToken).ConfigureAwait(false); + parentChanges.Add(Parse(output, parent, commit.SHA)); + } + } + + changes.AddRange(MergeParents(parentChanges)); + } + + cancellationToken.ThrowIfCancellationRequested(); + return Models.CommitChange.Zip(changes); + } + + /// + /// Combined merge changes, as in the Rebased log: a path must differ from + /// every parent. Renames are matched by destination, not source path. + /// Retain the first parent's real endpoints for content loading. + /// + public static List MergeParents(IReadOnlyList> parents) + { + if (parents.Count == 0) + return []; + if (parents.Count == 1) + return parents[0]; + + var maps = new List>(); + for (var i = 1; i < parents.Count; i++) + { + var map = new Dictionary(StringComparer.Ordinal); + foreach (var change in parents[i]) + map[change.Path] = change; + maps.Add(map); + } + + var result = new List(); + foreach (var first in parents[0]) + { + var presentInAll = true; + var status = first.Index; + foreach (var map in maps) + { + if (!map.TryGetValue(first.Path, out var other)) + { + presentInAll = false; + break; + } + + if (status != other.Index) + status = status == Models.ChangeState.Deleted || other.Index == Models.ChangeState.Deleted ? + Models.ChangeState.Deleted : Models.ChangeState.Modified; + else if (status == Models.ChangeState.Renamed && !first.BeforePath.Equals(other.BeforePath, StringComparison.Ordinal)) + status = Models.ChangeState.Modified; + } + + if (!presentInAll) + continue; + + first.Index = status; + if (status != Models.ChangeState.Renamed) + first.OriginalPath = string.Empty; + result.Add(first); + } + + return result; + } + + public static List Parse(string output, string beforeRevision, string afterRevision) + { + var result = new List(); + var fields = output.Split('\0'); + for (var i = 0; i < fields.Length - 1;) + { + var header = fields[i++].Split(' '); + if (header.Length != 5 || !header[0].StartsWith(':') || i >= fields.Length - 1) + throw new InvalidDataException("Invalid raw diff from Git."); + + var path = fields[i++]; + var status = header[4][0]; + var target = path; + if (status is 'R' or 'C') + { + if (i >= fields.Length - 1) + throw new InvalidDataException("Missing rename destination from Git."); + target = fields[i++]; + } + + var change = new Models.CommitChange + { + BeforeRevision = status == 'A' ? string.Empty : beforeRevision, + BeforePath = status == 'A' ? string.Empty : path, + BeforeMode = header[0][1..], + BeforeObject = status == 'A' ? string.Empty : header[2], + AfterRevision = status == 'D' ? string.Empty : afterRevision, + AfterPath = status == 'D' ? string.Empty : target, + AfterMode = header[1], + AfterObject = status == 'D' ? string.Empty : header[3], + }; + change.UpdateStatus(); + result.Add(change); + } + + return result; + } + + private async Task ReadRawAsync(CancellationToken cancellationToken) + { + using var process = new Process { StartInfo = CreateGitStartInfo(true) }; + process.Start(); + // Drain both pipes concurrently, and own the process lifetime when a + // newer selection cancels this read-only query. + var stdout = process.StandardOutput.ReadToEndAsync(); + var stderr = process.StandardError.ReadToEndAsync(); + try + { + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + try + { + if (!process.HasExited) + process.Kill(true); + } + catch (InvalidOperationException) + { + // The process may exit between HasExited and Kill. + } + await process.WaitForExitAsync().ConfigureAwait(false); + await Task.WhenAll(stdout, stderr).ConfigureAwait(false); + throw; + } + + var output = await stdout.ConfigureAwait(false); + var error = await stderr.ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + if (process.ExitCode != 0) + throw new InvalidOperationException(error.Trim()); + return output; + } + } +} diff --git a/src/Models/CommitChange.cs b/src/Models/CommitChange.cs new file mode 100644 index 0000000000..143a419aea --- /dev/null +++ b/src/Models/CommitChange.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; + +namespace SourceGit.Models +{ + /// + /// A change with independent, immutable historical endpoints. An empty revision + /// denotes an absent file, never the working copy. + /// + public class CommitChange : Change + { + public string BeforeRevision { get; set; } = string.Empty; + public string AfterRevision { get; set; } = string.Empty; + public string BeforePath { get; set; } = string.Empty; + public string AfterPath { get; set; } = string.Empty; + public string BeforeObject { get; set; } = string.Empty; + public string AfterObject { get; set; } = string.Empty; + public string BeforeMode { get; set; } = "000000"; + public string AfterMode { get; set; } = "000000"; + + public void UpdateStatus() + { + Path = string.IsNullOrEmpty(AfterRevision) ? BeforePath : AfterPath; + OriginalPath = string.Empty; + if (string.IsNullOrEmpty(BeforeRevision)) + Index = ChangeState.Added; + else if (string.IsNullOrEmpty(AfterRevision)) + Index = ChangeState.Deleted; + else if (!BeforePath.Equals(AfterPath, StringComparison.Ordinal)) + { + Index = ChangeState.Renamed; + OriginalPath = BeforePath; + } + else if (BeforeMode[..3] != AfterMode[..3]) + Index = ChangeState.TypeChanged; + else + Index = ChangeState.Modified; + } + + /// + /// Join changes oldest first, following paths, like the log's zipped changes + /// in JetBrains clients. This is not a cherry-pick or a whole-range diff: + /// only selected changes introduce paths, but joined endpoints may include + /// intervening edits to those paths. Deletion followed by addition remains + /// two changes, while addition followed by deletion cancels out. + /// + public static List Zip(IEnumerable changes) + { + var result = new List(); + var byAfterPath = new Dictionary>(StringComparer.Ordinal); + foreach (var change in changes) + { + var combined = change; + if (!string.IsNullOrEmpty(change.BeforeRevision) && + byAfterPath.TryGetValue(change.BeforePath, out var previous) && previous.Count > 0) + { + var first = previous.Dequeue(); + result.Remove(first); + if (string.IsNullOrEmpty(first.BeforeRevision) && string.IsNullOrEmpty(change.AfterRevision)) + continue; + + combined = new CommitChange + { + BeforeRevision = first.BeforeRevision, + BeforePath = first.BeforePath, + BeforeObject = first.BeforeObject, + BeforeMode = first.BeforeMode, + AfterRevision = change.AfterRevision, + AfterPath = change.AfterPath, + AfterObject = change.AfterObject, + AfterMode = change.AfterMode, + }; + combined.UpdateStatus(); + } + + result.Add(combined); + if (!string.IsNullOrEmpty(combined.AfterRevision)) + { + if (!byAfterPath.TryGetValue(combined.AfterPath, out var bucket)) + { + bucket = new Queue(); + byAfterPath.Add(combined.AfterPath, bucket); + } + bucket.Enqueue(combined); + } + } + + return result; + } + } +} diff --git a/src/Models/DiffOption.cs b/src/Models/DiffOption.cs index af64b39a4b..f525abaec0 100644 --- a/src/Models/DiffOption.cs +++ b/src/Models/DiffOption.cs @@ -11,6 +11,7 @@ public class DiffOption public List Revisions => _revisions; public string Path => _path; public string OrgPath => _orgPath; + public CommitChange CommitChange { get; } /// /// Only used for working copy changes @@ -127,11 +128,42 @@ public DiffOption(string baseRevision, string targetRevision, Change change) _orgPath = change.OriginalPath; } + /// + /// Compare the exact endpoints of a joined commit change. Keep revision IDs + /// separate from blob IDs so image and binary readers can resolve each path. + /// + public DiffOption(CommitChange change) + { + CommitChange = change; + var revision = string.IsNullOrEmpty(change.BeforeRevision) ? change.AfterRevision : change.BeforeRevision; + var emptyTree = EmptyTreeHash.Guess(revision); + _revisions.Add(string.IsNullOrEmpty(change.BeforeRevision) ? emptyTree : change.BeforeRevision); + _revisions.Add(string.IsNullOrEmpty(change.AfterRevision) ? emptyTree : change.AfterRevision); + _path = string.IsNullOrEmpty(change.AfterPath) ? change.BeforePath : change.AfterPath; + _orgPath = !string.IsNullOrEmpty(change.BeforePath) && change.BeforePath != _path ? change.BeforePath : string.Empty; + + var hasBefore = !string.IsNullOrEmpty(change.BeforeRevision) && change.BeforeMode != "160000"; + var hasAfter = !string.IsNullOrEmpty(change.AfterRevision) && change.AfterMode != "160000"; + if (hasBefore && hasAfter && change.BeforePath.Equals(change.AfterPath, StringComparison.Ordinal)) + _endpointArguments = $"{change.BeforeRevision} {change.AfterRevision} -- {QuoteEndpointPath(change.AfterPath)}"; + else if (hasBefore && hasAfter) + _endpointArguments = $"{change.BeforeObject} {change.AfterObject}"; + else if (hasBefore) + _endpointArguments = $"{change.BeforeRevision} {emptyTree} -- {QuoteEndpointPath(change.BeforePath)}"; + else if (hasAfter) + _endpointArguments = $"{emptyTree} {change.AfterRevision} -- {QuoteEndpointPath(change.AfterPath)}"; + else + _endpointArguments = $"{_revisions[0]} {_revisions[1]} -- {QuoteEndpointPath(_path)}"; + } + /// /// Converts to diff command arguments. /// public override string ToString() { + if (_endpointArguments != null) + return _endpointArguments; + var builder = new StringBuilder(); if (!string.IsNullOrEmpty(_extra)) builder.Append($"{_extra} "); @@ -149,6 +181,30 @@ public override string ToString() return builder.ToString(); } + // ProcessStartInfo.Arguments uses command-line quoting, not shell quoting. + // Backslashes must be doubled before a quote and before the closing quote. + private static string QuoteEndpointPath(string path) + { + var builder = new StringBuilder("\":(literal)"); + var backslashes = 0; + foreach (var c in path) + { + if (c == '\\') + { + backslashes++; + continue; + } + + builder.Append('\\', c == '\"' ? backslashes * 2 + 1 : backslashes); + builder.Append(c); + backslashes = 0; + } + builder.Append('\\', backslashes * 2); + builder.Append('\"'); + return builder.ToString(); + } + + private readonly string _endpointArguments = null; private readonly bool _isUnstaged = false; private readonly string _path; private readonly string _orgPath = string.Empty; diff --git a/src/Resources/Locales/de_DE.axaml b/src/Resources/Locales/de_DE.axaml index 3ba62d7aac..b7f4586b42 100644 --- a/src/Resources/Locales/de_DE.axaml +++ b/src/Resources/Locales/de_DE.axaml @@ -860,6 +860,9 @@ $1, $2, … Werte der Eingabe-Steuerelemente Durchsuche Repositorys Stammverzeichnis: Anderes benutzerdefiniertes Verzeichnis durchsuchen + Only files changed by selected commits are included; intervening edits to those files may appear. Merge commits include paths changed against every parent, compared with the first parent. + No changes match this selection or filter. + Changes from {0} selected commits Suche nach Aktualisierungen… Eine neue Version dieses Programms ist verfügbar: Derzeitige Version: @@ -1026,4 +1029,5 @@ $1, $2, … Werte der Eingabe-Steuerelemente Entfernen Entsperren JA + Compare these two revisions (not selected changes) diff --git a/src/Resources/Locales/el_GR.axaml b/src/Resources/Locales/el_GR.axaml index 8b2bc2010a..bf44a9ffed 100644 --- a/src/Resources/Locales/el_GR.axaml +++ b/src/Resources/Locales/el_GR.axaml @@ -856,6 +856,9 @@ Σάρωση αποθετηρίων Ριζικός φάκελος: Σάρωση άλλου προσαρμοσμένου φακέλου + Only files changed by selected commits are included; intervening edits to those files may appear. Merge commits include paths changed against every parent, compared with the first parent. + No changes match this selection or filter. + Changes from {0} selected commits Έλεγχος για ενημερώσεις... Είναι διαθέσιμη νέα έκδοση αυτού του λογισμικού: Τρέχουσα έκδοση: @@ -1021,4 +1024,5 @@ Αφαίρεση Ξεκλείδωμα ΝΑΙ + Compare these two revisions (not selected changes) diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index d3ffb203a4..b56aa1fe06 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -887,6 +887,9 @@ Scan Repositories Root Dir: Scan another custom directory + Only files changed by selected commits are included; intervening edits to those files may appear. Merge commits include paths changed against every parent, compared with the first parent. + No changes match this selection or filter. + Changes from {0} selected commits Check for Updates... New version of this software is available: Current Version: @@ -1088,4 +1091,5 @@ Remove Unlock YES + Compare these two revisions (not selected changes) diff --git a/src/Resources/Locales/es_ES.axaml b/src/Resources/Locales/es_ES.axaml index 96ea1ec582..23a0b91700 100644 --- a/src/Resources/Locales/es_ES.axaml +++ b/src/Resources/Locales/es_ES.axaml @@ -890,6 +890,9 @@ Escanear Repositorios Directorio Raíz: Escanear otro directorio personalizado + Only files changed by selected commits are included; intervening edits to those files may appear. Merge commits include paths changed against every parent, compared with the first parent. + No changes match this selection or filter. + Changes from {0} selected commits Buscar Actualizaciones... Nueva versión de este software disponible: Versión Actual: @@ -1091,4 +1094,5 @@ Eliminar Desbloquear SÍ + Compare these two revisions (not selected changes) diff --git a/src/Resources/Locales/fr_FR.axaml b/src/Resources/Locales/fr_FR.axaml index 05b98ea1ec..43bea5e2c2 100644 --- a/src/Resources/Locales/fr_FR.axaml +++ b/src/Resources/Locales/fr_FR.axaml @@ -820,6 +820,9 @@ Analyser les repositories Dossier racine : Scanner un autre répertoire personnalisé + Only files changed by selected commits are included; intervening edits to those files may appear. Merge commits include paths changed against every parent, compared with the first parent. + No changes match this selection or filter. + Changes from {0} selected commits Rechercher des mises à jour... Une nouvelle version du logiciel est disponible : Version actuelle : @@ -981,4 +984,5 @@ Supprimer Déverrouiller OUI + Compare these two revisions (not selected changes) diff --git a/src/Resources/Locales/he_IL.axaml b/src/Resources/Locales/he_IL.axaml index 0abcb3b0c6..5f149a8f7d 100644 --- a/src/Resources/Locales/he_IL.axaml +++ b/src/Resources/Locales/he_IL.axaml @@ -820,6 +820,9 @@ סרוק מאגרים תיקיית שורש: סרוק תיקייה מותאמת אישית אחרת + Only files changed by selected commits are included; intervening edits to those files may appear. Merge commits include paths changed against every parent, compared with the first parent. + No changes match this selection or filter. + Changes from {0} selected commits בדוק עדכונים... גרסה חדשה של התוכנה זמינה: גרסה נוכחית: @@ -981,4 +984,5 @@ הסר שחרר נעילה כן + Compare these two revisions (not selected changes) diff --git a/src/Resources/Locales/id_ID.axaml b/src/Resources/Locales/id_ID.axaml index 5e7e5e57f9..e855a7a61b 100644 --- a/src/Resources/Locales/id_ID.axaml +++ b/src/Resources/Locales/id_ID.axaml @@ -861,6 +861,9 @@ Pindai Repositori Direktori Root: Pindai direktori kustom lain + Only files changed by selected commits are included; intervening edits to those files may appear. Merge commits include paths changed against every parent, compared with the first parent. + No changes match this selection or filter. + Changes from {0} selected commits Periksa Pembaruan... Versi baru dari perangkat lunak ini tersedia: Versi Saat Ini: @@ -1026,4 +1029,5 @@ Hapus Unlock YA + Compare these two revisions (not selected changes) diff --git a/src/Resources/Locales/it_IT.axaml b/src/Resources/Locales/it_IT.axaml index 630746c5d8..187b087490 100644 --- a/src/Resources/Locales/it_IT.axaml +++ b/src/Resources/Locales/it_IT.axaml @@ -765,6 +765,9 @@ ${pure_files:N} Come ${files:N}, ma senza cartelle Scansiona Repository Cartella Principale: Scansiona un'altra cartella personalizzata + Only files changed by selected commits are included; intervening edits to those files may appear. Merge commits include paths changed against every parent, compared with the first parent. + No changes match this selection or filter. + Changes from {0} selected commits Controlla Aggiornamenti... È disponibile una nuova versione del software: Errore durante il controllo degli aggiornamenti! @@ -917,4 +920,5 @@ ${pure_files:N} Come ${files:N}, ma senza cartelle Rimuovi Sblocca SÌ + Compare these two revisions (not selected changes) diff --git a/src/Resources/Locales/ja_JP.axaml b/src/Resources/Locales/ja_JP.axaml index 023fc65dfe..876ce290c2 100644 --- a/src/Resources/Locales/ja_JP.axaml +++ b/src/Resources/Locales/ja_JP.axaml @@ -880,6 +880,9 @@ リポジトリをスキャン ルートディレクトリ: 任意の別ディレクトリをスキャン + Only files changed by selected commits are included; intervening edits to those files may appear. Merge commits include paths changed against every parent, compared with the first parent. + No changes match this selection or filter. + Changes from {0} selected commits 更新を確認... このソフトウェアの新しいバージョンが利用できます: 現在のバージョン: @@ -1069,4 +1072,5 @@ 削除 ロックを解除 はい + Compare these two revisions (not selected changes) diff --git a/src/Resources/Locales/ko_KR.axaml b/src/Resources/Locales/ko_KR.axaml index 219cddf685..6e5a2bf96c 100644 --- a/src/Resources/Locales/ko_KR.axaml +++ b/src/Resources/Locales/ko_KR.axaml @@ -828,6 +828,9 @@ 저장소 스캔 루트 디렉터리: 다른 사용자 정의 디렉터리 스캔 + Only files changed by selected commits are included; intervening edits to those files may appear. Merge commits include paths changed against every parent, compared with the first parent. + No changes match this selection or filter. + Changes from {0} selected commits 업데이트 확인... 이 소프트웨어의 새 버전을 사용할 수 있습니다: 현재 버전: @@ -989,4 +992,5 @@ 제거 잠금 해제 예 + Compare these two revisions (not selected changes) diff --git a/src/Resources/Locales/pt_BR.axaml b/src/Resources/Locales/pt_BR.axaml index 5084f0180b..f5a68f2d2f 100644 --- a/src/Resources/Locales/pt_BR.axaml +++ b/src/Resources/Locales/pt_BR.axaml @@ -543,6 +543,9 @@ Patch salvo com sucesso! Escanear Repositórios Diretório Raiz: + Only files changed by selected commits are included; intervening edits to those files may appear. Merge commits include paths changed against every parent, compared with the first parent. + No changes match this selection or filter. + Changes from {0} selected commits Verificar atualizações... Nova versão deste software disponível: Falha ao verificar atualizações! @@ -636,4 +639,5 @@ Bloquear Remover Desbloquear + Compare these two revisions (not selected changes) diff --git a/src/Resources/Locales/ru_RU.axaml b/src/Resources/Locales/ru_RU.axaml index 5aef219dbd..f599c3572c 100644 --- a/src/Resources/Locales/ru_RU.axaml +++ b/src/Resources/Locales/ru_RU.axaml @@ -890,6 +890,9 @@ Обнаружение репозиториев Корневой каталог: Сканировать другой пользовательский каталог + Only files changed by selected commits are included; intervening edits to those files may appear. Merge commits include paths changed against every parent, compared with the first parent. + No changes match this selection or filter. + Changes from {0} selected commits Проверить обновления... Доступна новая версия программного обеспечения: Текущая версия: @@ -1091,4 +1094,5 @@ Удалить Разблокировать Да + Compare these two revisions (not selected changes) diff --git a/src/Resources/Locales/ta_IN.axaml b/src/Resources/Locales/ta_IN.axaml index 3473ff1cab..4fb5e05ee8 100644 --- a/src/Resources/Locales/ta_IN.axaml +++ b/src/Resources/Locales/ta_IN.axaml @@ -552,6 +552,9 @@ ஒட்டு வெற்றிகரமாக சேமிக்கப்பட்டது! களஞ்சியங்களை வருடு வேர் அடைவு: + Only files changed by selected commits are included; intervening edits to those files may appear. Merge commits include paths changed against every parent, compared with the first parent. + No changes match this selection or filter. + Changes from {0} selected commits புதுப்பிப்புகளைச் சரிபார்... இந்த மென்பொருளின் புதிய பதிப்பு கிடைக்கிறது: புதுப்பிப்புகளைச் சரிபார்க்க முடியவில்லை! @@ -653,4 +656,5 @@ பூட்டு நீக்கு திற + Compare these two revisions (not selected changes) diff --git a/src/Resources/Locales/uk_UA.axaml b/src/Resources/Locales/uk_UA.axaml index 5f44193e39..a7febd1f0e 100644 --- a/src/Resources/Locales/uk_UA.axaml +++ b/src/Resources/Locales/uk_UA.axaml @@ -556,6 +556,9 @@ Патч успішно збережено! Сканувати сховища Коренева тека: + Only files changed by selected commits are included; intervening edits to those files may appear. Merge commits include paths changed against every parent, compared with the first parent. + No changes match this selection or filter. + Changes from {0} selected commits Перевірити оновлення... Доступна нова версія програми: Не вдалося перевірити оновлення! @@ -661,4 +664,5 @@ Заблокувати Видалити Розблокувати + Compare these two revisions (not selected changes) diff --git a/src/Resources/Locales/zh_CN.axaml b/src/Resources/Locales/zh_CN.axaml index 90eb61af95..88fa996b91 100644 --- a/src/Resources/Locales/zh_CN.axaml +++ b/src/Resources/Locales/zh_CN.axaml @@ -891,6 +891,9 @@ 扫描仓库 根路径 : 扫描其他自定义路径 + 汇总所选提交涉及的文件;跳过提交独有的文件不会出现,但同一文件的中间改动可能包含在差异中。合并提交只包含相对所有父提交均有变化的文件,内容以第一父提交为基准。 + 所选提交或当前筛选条件下没有改动。 + 已选 {0} 个提交的汇总改动 检测更新... 检测到软件有版本更新: 当前版本 : @@ -1092,4 +1095,5 @@ 移除工作树 解除工作树锁定 好的 + 比较这两个版本(不汇总提交) diff --git a/src/Resources/Locales/zh_TW.axaml b/src/Resources/Locales/zh_TW.axaml index ef2e3d7ca7..ca501d1db8 100644 --- a/src/Resources/Locales/zh_TW.axaml +++ b/src/Resources/Locales/zh_TW.axaml @@ -891,6 +891,9 @@ 掃描存放庫 頂層目錄: 掃描其他自訂目錄 + Only files changed by selected commits are included; intervening edits to those files may appear. Merge commits include paths changed against every parent, compared with the first parent. + No changes match this selection or filter. + Changes from {0} selected commits 檢查更新... 軟體有版本更新: 目前版本: @@ -1092,4 +1095,5 @@ 移除工作樹 解除鎖定工作樹 是 + Compare these two revisions (not selected changes) diff --git a/src/ViewModels/CommitChanges.cs b/src/ViewModels/CommitChanges.cs new file mode 100644 index 0000000000..ef6e38bd78 --- /dev/null +++ b/src/ViewModels/CommitChanges.cs @@ -0,0 +1,217 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class CommitChanges : ObservableObject, IDisposable + { + public int CommitCount => _commits.Count; + public IReadOnlyList Commits { get; } + public IReadOnlyList InformationDetails { get; } + + public CommitDetail SelectedInformationDetail + { + get => _selectedInformationDetail; + set + { + if (value != null && InformationDetails.Contains(value)) + SelectedCommit = value.Commit; + } + } + + public int ActiveTabIndex + { + get => _activeTabIndex; + set + { + if (!_disposed && value is >= 0 and <= 2 && SetProperty(ref _activeTabIndex, value) && value == 2) + EnsureRevisionDetail(); + } + } + + public Models.Commit SelectedCommit + { + get => _selectedCommit; + set + { + if (_disposed || value == null || !_commits.Contains(value)) + return; + if (SetProperty(ref _selectedCommit, value)) + { + _selectedInformationDetail = InformationDetails.First(detail => detail.Commit == value); + OnPropertyChanged(nameof(SelectedInformationDetail)); + if (_revisionDetail != null) + { + _revisionDetail.Dispose(); + RevisionDetail = null; + EnsureRevisionDetail(); + } + } + } + } + + public CommitDetail RevisionDetail + { + get => _revisionDetail; + private set => SetProperty(ref _revisionDetail, value); + } + + private void EnsureRevisionDetail() + { + if (_revisionDetail == null && _selectedCommit != null && !_disposed) + RevisionDetail = new CommitDetail(_repo, null) { ActiveTabIndex = 2, Commit = _selectedCommit }; + } + + public bool IsLoading + { + get => _isLoading; + private set => SetProperty(ref _isLoading, value); + } + public string Error + { + get => _error; + private set => SetProperty(ref _error, value); + } + public int TotalChanges => _changes.Count; + public List VisibleChanges + { + get => _visibleChanges; + private set => SetProperty(ref _visibleChanges, value); + } + public string SearchFilter + { + get => _searchFilter; + set + { + if (SetProperty(ref _searchFilter, value)) + RefreshVisible(); + } + } + public ChangeSelection ChangeSelection + { + get => _changeSelection; + set + { + if (SetProperty(ref _changeSelection, value)) + { + DiffContext = value is { Count: 1, HasFolder: false } && value.Changes[0] is Models.CommitChange change + ? new DiffContext(_repo.FullPath, new Models.DiffOption(change)) + : null; + } + } + } + public DiffContext DiffContext + { + get => _diffContext; + private set => SetProperty(ref _diffContext, value); + } + + public CommitChanges(Repository repo, List commits) + { + _repo = repo; + _commits = new List(commits); + Commits = _commits.AsReadOnly(); + // Virtualized card views start these queries only when realized. A shared + // gate also bounds git processes when scrolling quickly through many cards. + var gate = new SemaphoreSlim(2); + InformationDetails = _commits.Select(commit => new CommitDetail(repo, + new CommitDetailSharedData { ActiveTabIndex = 0 }, true, gate) + { Commit = commit }).ToList().AsReadOnly(); + _selectedCommit = _commits.Count > 0 ? _commits[0] : null; + _selectedInformationDetail = InformationDetails.FirstOrDefault(); + _ = LoadAsync(_cancellation.Token); + } + + public void ClearSearchFilter() => SearchFilter = string.Empty; + + public void OpenChangeWithExternalDiffTool(Models.Change change) + { + if (change is Models.CommitChange selected && selected.BeforeMode != "160000" && selected.AfterMode != "160000") + new Commands.DiffTool(_repo.FullPath, new Models.DiffOption(selected)).Open(); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + _revisionDetail?.Dispose(); + foreach (var detail in InformationDetails) + detail.Dispose(); + _cancellation.Cancel(); + _cancellation.Dispose(); + } + + private async Task LoadAsync(CancellationToken cancellationToken) + { + try + { + var changes = await new Commands.QuerySelectedCommitChanges(_repo.FullPath) + .ReadAsync(_commits, cancellationToken).ConfigureAwait(false); + changes.Sort((l, r) => Models.NumericSort.Compare(l.Path, r.Path)); + await Dispatcher.UIThread.InvokeAsync(() => + { + if (_disposed || cancellationToken.IsCancellationRequested) + return; + _changes = new List(changes); + OnPropertyChanged(nameof(TotalChanges)); + IsLoading = false; + RefreshVisible(); + }); + } + catch (OperationCanceledException) + { + // A later history selection owns the details pane now. + } + catch (Exception ex) + { + await Dispatcher.UIThread.InvokeAsync(() => + { + if (_disposed || cancellationToken.IsCancellationRequested) + return; + Error = ex.Message; + IsLoading = false; + }); + } + } + + private void RefreshVisible() + { + var visible = new List(); + foreach (var change in _changes) + { + if (string.IsNullOrWhiteSpace(_searchFilter) || + change.Path.Contains(_searchFilter, StringComparison.OrdinalIgnoreCase) || + change.OriginalPath.Contains(_searchFilter, StringComparison.OrdinalIgnoreCase)) + visible.Add(change); + } + VisibleChanges = visible; + var previous = _changeSelection is { Count: 1, HasFolder: false } ? _changeSelection.Changes[0] : null; + ChangeSelection = previous != null && visible.Contains(previous) + ? new ChangeSelection(new List { previous }) + : new ChangeSelection(visible.Count > 0 ? visible.GetRange(0, 1) : null); + } + + private readonly Repository _repo; + private readonly List _commits; + private readonly CancellationTokenSource _cancellation = new(); + private int _activeTabIndex = 1; + private Models.Commit _selectedCommit; + private CommitDetail _revisionDetail; + private CommitDetail _selectedInformationDetail; + private bool _disposed; + private bool _isLoading = true; + private string _error = string.Empty; + private string _searchFilter = string.Empty; + private List _changes = []; + private List _visibleChanges = []; + private ChangeSelection _changeSelection = new(null); + private DiffContext _diffContext; + } +} diff --git a/src/ViewModels/CommitDetail.cs b/src/ViewModels/CommitDetail.cs index 636aad6654..a8a0df396e 100644 --- a/src/ViewModels/CommitDetail.cs +++ b/src/ViewModels/CommitDetail.cs @@ -25,7 +25,7 @@ public CommitDetailSharedData() } } - public partial class CommitDetail : ObservableObject + public partial class CommitDetail : ObservableObject, IDisposable { public Repository Repository { @@ -57,7 +57,11 @@ public Models.Commit Commit return; if (SetProperty(ref _commit, value)) - Refresh(); + { + _informationLoaded = false; + if (!_deferInformationLoading) + EnsureInformationLoaded(); + } } } @@ -157,16 +161,37 @@ public Vector ScrollOffset set => SetProperty(ref _scrollOffset, value); } - public CommitDetail(Repository repo, CommitDetailSharedData sharedData) + public CommitDetail(Repository repo, CommitDetailSharedData sharedData, bool deferInformationLoading = false, SemaphoreSlim informationLoadGate = null) { _repo = repo; + _deferInformationLoading = deferInformationLoading; + _informationLoadGate = informationLoadGate; _sharedData = sharedData ?? new CommitDetailSharedData(); WebLinks = Models.CommitLink.Get(repo.Remotes); } - public CommitDetail Clone() + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + _revisionFileGeneration++; + _cancellationSource?.Cancel(); + _cancellationSource?.Dispose(); + } + + public void EnsureInformationLoaded() + { + if (_disposed || _informationLoaded) + return; + _informationLoaded = true; + Refresh(); + } + + public CommitDetail Clone(Models.Change selectedChange = null) { var cloned = new CommitDetail(_repo, null); + cloned._initialChange = selectedChange; cloned.ActiveTabIndex = ActiveTabIndex; cloned.Commit = _commit; return cloned; @@ -407,18 +432,23 @@ public async Task ResetMultipleToParentRevisionAsync(List changes public async Task ViewRevisionFileAsync(Models.Object file) { + if (_disposed) + return; + var generation = ++_revisionFileGeneration; + var revision = _commit?.SHA; var obj = file ?? new Models.Object() { Path = string.Empty, Type = Models.ObjectType.None }; ViewRevisionFilePath = obj.Path; + ViewRevisionFileContent = null; switch (obj.Type) { case Models.ObjectType.Blob: CanOpenRevisionFileWithDefaultEditor = true; - await SetViewingBlobAsync(obj); + await SetViewingBlobAsync(obj, revision, generation); break; case Models.ObjectType.Commit: CanOpenRevisionFileWithDefaultEditor = false; - await SetViewingCommitAsync(obj); + await SetViewingCommitAsync(obj, generation); break; default: CanOpenRevisionFileWithDefaultEditor = false; @@ -453,9 +483,16 @@ await Commands.SaveRevisionFile private void Refresh() { + if (_disposed) + return; + _revisionFileGeneration++; + _cancellationSource?.Cancel(); + _cancellationSource?.Dispose(); + _cancellationSource = null; _requestingRevisionFiles = false; _revisionFiles = null; + FullMessage = null; SignInfo = null; ViewRevisionFileContent = null; ViewRevisionFilePath = string.Empty; @@ -472,15 +509,13 @@ private void Refresh() return; } - if (_cancellationSource is { IsCancellationRequested: false }) - _cancellationSource.Cancel(); - _cancellationSource = new CancellationTokenSource(); var token = _cancellationSource.Token; + var commit = _commit; - Task.Run(async () => + RunInformationQuery(async () => { - var message = await new Commands.QueryCommitFullMessage(_repo.FullPath, _commit.SHA) + var message = await new Commands.QueryCommitFullMessage(_repo.FullPath, commit.SHA) .GetResultAsync() .ConfigureAwait(false); var inlines = await ParseInlinesInMessageAsync(message).ConfigureAwait(false); @@ -488,6 +523,8 @@ private void Refresh() if (!token.IsCancellationRequested) Dispatcher.UIThread.Post(() => { + if (_disposed || token.IsCancellationRequested) + return; FullMessage = new Models.CommitFullMessage { Message = message, @@ -496,19 +533,23 @@ private void Refresh() }); }, token); - Task.Run(async () => + RunInformationQuery(async () => { - var signInfo = await new Commands.QueryCommitSignInfo(_repo.FullPath, _commit.SHA, !_repo.HasAllowedSignersFile) + var signInfo = await new Commands.QueryCommitSignInfo(_repo.FullPath, commit.SHA, !_repo.HasAllowedSignersFile) .GetResultAsync() .ConfigureAwait(false); if (!token.IsCancellationRequested) - Dispatcher.UIThread.Post(() => SignInfo = signInfo); + Dispatcher.UIThread.Post(() => + { + if (!_disposed && !token.IsCancellationRequested) + SignInfo = signInfo; + }); }, token); - Task.Run(async () => + RunInformationQuery(async () => { - var changes = await new Commands.CompareRevisions(_repo.FullPath, _commit.FirstParentToCompare, _commit.SHA) + var changes = await new Commands.CompareRevisions(_repo.FullPath, commit.FirstParentToCompare, commit.SHA) .ReadAsync() .ConfigureAwait(false); @@ -527,18 +568,48 @@ private void Refresh() { Dispatcher.UIThread.Post(() => { + if (_disposed || token.IsCancellationRequested) + return; Changes = changes; VisibleChanges = visible; if (visible.Count == 0) ChangeSelection = new(null); else - ChangeSelection = new(VisibleChanges.GetRange(0, 1)); + ChangeSelection = new(new List { visible.Find(c => c.Path == _initialChange?.Path) ?? visible[0] }); + _initialChange = null; }); } }, token); } + private void RunInformationQuery(Func query, CancellationToken token) + { + _ = Task.Run(async () => + { + var entered = false; + try + { + if (_informationLoadGate != null) + { + await _informationLoadGate.WaitAsync(token).ConfigureAwait(false); + entered = true; + } + token.ThrowIfCancellationRequested(); + await query().ConfigureAwait(false); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + // A newer selection owns the pane; queued card work is discarded. + } + finally + { + if (entered) + _informationLoadGate.Release(); + } + }); + } + private void UpdateDetails() { if (ActiveTabIndex == 1 && _changeSelection is { Count: 1, HasFolder: false }) @@ -637,7 +708,7 @@ private void RefreshRevisionSearchSuggestion() Dispatcher.UIThread.Post(() => { - if (sha == Commit.SHA && _requestingRevisionFiles) + if (!_disposed && sha == Commit.SHA && _requestingRevisionFiles) { _revisionFiles = files; _requestingRevisionFiles = false; @@ -676,28 +747,39 @@ private void CalcRevisionFileSearchSuggestion() RevisionFileSearchSuggestion = suggestion; } - private async Task SetViewingBlobAsync(Models.Object file) + private async Task SetViewingBlobAsync(Models.Object file, string revision, int generation) { - var isBinary = await new Commands.IsBinary(_repo.FullPath, _commit.SHA, file.Path).GetResultAsync(); + var isBinary = await new Commands.IsBinary(_repo.FullPath, revision, file.Path).GetResultAsync(); + if (_disposed || generation != _revisionFileGeneration) + return; if (isBinary) { var imgDecoder = ImageSource.GetDecoder(file.Path); if (imgDecoder != Models.ImageDecoder.None) { - var source = await ImageSource.FromRevisionAsync(_repo.FullPath, _commit.SHA, file.Path, imgDecoder); + var source = await ImageSource.FromRevisionAsync(_repo.FullPath, revision, file.Path, imgDecoder); + if (_disposed || generation != _revisionFileGeneration) + { + source.Bitmap?.Dispose(); + return; + } ViewRevisionFileContent = new Models.RevisionImageFile(file.Path, source.Bitmap, source.Size); } else { - var size = await new Commands.QueryFileSize(_repo.FullPath, file.Path, _commit.SHA).GetResultAsync(); - ViewRevisionFileContent = new Models.RevisionBinaryFile(_repo.FullPath, file.Path, _commit.SHA, size); + var size = await new Commands.QueryFileSize(_repo.FullPath, file.Path, revision).GetResultAsync(); + if (_disposed || generation != _revisionFileGeneration) + return; + ViewRevisionFileContent = new Models.RevisionBinaryFile(_repo.FullPath, file.Path, revision, size); } return; } - await using var contentStream = await Commands.QueryFileContent.RunAsync(_repo.FullPath, _commit.SHA, file.Path); + await using var contentStream = await Commands.QueryFileContent.RunAsync(_repo.FullPath, revision, file.Path); var content = await new StreamReader(contentStream).ReadToEndAsync(); + if (_disposed || generation != _revisionFileGeneration) + return; var lfs = Models.LFSObject.Parse(content); if (lfs != null) { @@ -713,10 +795,12 @@ private async Task SetViewingBlobAsync(Models.Object file) } } - private async Task SetViewingCommitAsync(Models.Object file) + private async Task SetViewingCommitAsync(Models.Object file, int generation) { var submoduleRoot = Path.Combine(_repo.FullPath, file.Path).Replace('\\', '/').TrimEnd('/'); - ViewRevisionFileContent = await new Commands.QuerySubmoduleRevision(submoduleRoot, file.SHA).GetResultAsync(); + var content = await new Commands.QuerySubmoduleRevision(submoduleRoot, file.SHA).GetResultAsync(); + if (!_disposed && generation == _revisionFileGeneration) + ViewRevisionFileContent = content; } [GeneratedRegex(@"\b(https?://|ftp://)[\w\d\._/\-~%@()+:?&=#!]*[\w\d/]")] @@ -728,6 +812,12 @@ private async Task SetViewingCommitAsync(Models.Object file) [GeneratedRegex(@"`.*?`")] private static partial Regex REG_INLINECODE_FORMAT(); + private readonly bool _deferInformationLoading; + private readonly SemaphoreSlim _informationLoadGate; + private bool _informationLoaded; + private Models.Change _initialChange; + private bool _disposed; + private int _revisionFileGeneration; private Repository _repo = null; private CommitDetailSharedData _sharedData = null; private Models.Commit _commit = null; diff --git a/src/ViewModels/DiffContext.cs b/src/ViewModels/DiffContext.cs index ddbdffbb60..045b3ed33b 100644 --- a/src/ViewModels/DiffContext.cs +++ b/src/ViewModels/DiffContext.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Threading; using System.Threading.Tasks; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; @@ -116,6 +117,7 @@ public void CheckSettings() private void LoadContent() { + var generation = Interlocked.Increment(ref _generation); if (_option.Path.EndsWith('/')) { OldMode = 0; @@ -138,11 +140,29 @@ private void LoadContent() .ReadAsync() .ConfigureAwait(false); + // Blob-to-blob diffs omit file modes and have no meaningful path names. + // Restore metadata from the selected commits rather than inferred headers. + if (_option.CommitChange is { } joined) + { + latest.OldMode = int.TryParse(joined.BeforeMode, out var oldMode) ? oldMode : 0; + latest.NewMode = int.TryParse(joined.AfterMode, out var newMode) ? newMode : 0; + latest.OldHash = joined.BeforeObject; + latest.NewHash = joined.AfterObject; + if (latest.TextDiff is { } text) + { + text.OldMode = latest.OldMode; + text.NewMode = latest.NewMode; + text.OldHash = latest.OldHash; + text.NewHash = latest.NewHash; + } + } + var info = new Info(_option, numLines, ignoreWhitespace, latest); if (_info != null && info.IsSame(_info)) return; - _info = info; + if (generation != Volatile.Read(ref _generation)) + return; object rs = null; if (latest.TextDiff is { } textDiff) @@ -180,6 +200,10 @@ private void LoadContent() Dispatcher.UIThread.Post(() => { + if (generation != Volatile.Read(ref _generation)) + return; + + _info = info; OldMode = latest.OldMode; NewMode = latest.NewMode; @@ -219,7 +243,8 @@ private void LoadContent() } else { - var oldImage = await ImageSource.FromRevisionAsync(_repo, _option.Revisions[0], oldPath, imgDecoder).ConfigureAwait(false); + var oldRevision = _option.CommitChange?.BeforeMode == "160000" ? Models.EmptyTreeHash.Guess(_option.Revisions[0]) : _option.Revisions[0]; + var oldImage = await ImageSource.FromRevisionAsync(_repo, oldRevision, oldPath, imgDecoder).ConfigureAwait(false); imgDiff.Old = oldImage.Bitmap; imgDiff.OldFileSize = oldImage.Size; } @@ -232,7 +257,8 @@ private void LoadContent() } else { - var newImage = await ImageSource.FromRevisionAsync(_repo, _option.Revisions[1], _option.Path, imgDecoder).ConfigureAwait(false); + var newRevision = _option.CommitChange?.AfterMode == "160000" ? Models.EmptyTreeHash.Guess(_option.Revisions[1]) : _option.Revisions[1]; + var newImage = await ImageSource.FromRevisionAsync(_repo, newRevision, _option.Path, imgDecoder).ConfigureAwait(false); imgDiff.New = newImage.Bitmap; imgDiff.NewFileSize = newImage.Size; } @@ -278,7 +304,8 @@ private void LoadContent() if (_option.Revisions[0].Equals("-R", StringComparison.Ordinal)) // `-R` means the old side is the working tree binaryDiff.OldSize = File.Exists(fullPath) ? new FileInfo(fullPath).Length : 0; else - binaryDiff.OldSize = await new Commands.QueryFileSize(_repo, oldPath, _option.Revisions[0]).GetResultAsync().ConfigureAwait(false); + binaryDiff.OldSize = _option.CommitChange?.BeforeMode == "160000" ? 0 : + await new Commands.QueryFileSize(_repo, oldPath, _option.Revisions[0]).GetResultAsync().ConfigureAwait(false); if (string.IsNullOrEmpty(_option.Revisions[1])) // Empty string in the second revision means the new side is the working tree { @@ -287,7 +314,8 @@ private void LoadContent() } else { - binaryDiff.NewSize = await new Commands.QueryFileSize(_repo, _option.Path, _option.Revisions[1]).GetResultAsync().ConfigureAwait(false); + binaryDiff.NewSize = _option.CommitChange?.AfterMode == "160000" ? 0 : + await new Commands.QueryFileSize(_repo, _option.Path, _option.Revisions[1]).GetResultAsync().ConfigureAwait(false); binaryDiff.NewRevision = _option.Revisions[1]; } } @@ -383,6 +411,7 @@ public bool IsSame(Info other) private readonly int _entireFileLines = 999999999; private readonly string _repo; private readonly Models.DiffOption _option = null; + private int _generation = 0; private int _oldMode = 0; private int _newMode = 0; private int _unifiedLines = 4; diff --git a/src/ViewModels/Histories.cs b/src/ViewModels/Histories.cs index 128b53ff9d..159577279e 100644 --- a/src/ViewModels/Histories.cs +++ b/src/ViewModels/Histories.cs @@ -148,8 +148,12 @@ public object DetailContext get => _detailContext; set { - if (SetProperty(ref _detailContext, value)) - OnPropertyChanged(nameof(IsOpenAsStandaloneVisible)); + if (!ReferenceEquals(_detailContext, value)) + { + (_detailContext as CommitChanges)?.Dispose(); + if (SetProperty(ref _detailContext, value)) + OnPropertyChanged(nameof(IsOpenAsStandaloneVisible)); + } } } @@ -519,6 +523,17 @@ private void PostCommitsChanged() SelectedCommits = selected; } + public void CompareSelectedRevisions() + { + if (_selectedCommits.Count != 2) + return; + var left = _selectedCommits[0]; + var right = _selectedCommits[1]; + if (_commits.IndexOf(left) < _commits.IndexOf(right)) + (left, right) = (right, left); + DetailContext = new RevisionCompare(_repo, left, right); + } + private void PostSelectedCommitsChanged() { if (_ignoreSelectionChange) @@ -543,19 +558,27 @@ private void PostSelectedCommitsChanged() else DetailContext = new CommitDetail(_repo, _commitDetailSharedData) { Commit = c }; } - else if (_selectedCommits.Count == 2) - { - _searchCommitContext.Selected = null; - - if (_detailContext is RevisionCompare compare) - compare.SetTargets(_selectedCommits[1], _selectedCommits[0]); - else - DetailContext = new RevisionCompare(_repo, _selectedCommits[1], _selectedCommits[0]); - } else { _searchCommitContext.Selected = null; - DetailContext = new Models.Count(_selectedCommits.Count); + // SelectedItems follows click order. Aggregate in displayed history + // order instead, matching the log rather than timestamps. + var selected = new Dictionary(StringComparer.Ordinal); + foreach (var commit in _selectedCommits) + selected.TryAdd(commit.SHA, commit); + var ordered = new List(); + foreach (var commit in _commits) + { + if (selected.Remove(commit.SHA)) + ordered.Add(commit); + } + // Search results may refer to commits outside the loaded history. + foreach (var commit in _selectedCommits) + { + if (selected.Remove(commit.SHA)) + ordered.Add(commit); + } + DetailContext = new CommitChanges(_repo, ordered); } if (_repo.UIStates.GraphHighlighting >= Models.CommitGraphHighlighting.SelectedCommitsOnly) diff --git a/src/ViewModels/Repository.cs b/src/ViewModels/Repository.cs index 1ea5951a83..88505a5323 100644 --- a/src/ViewModels/Repository.cs +++ b/src/ViewModels/Repository.cs @@ -461,6 +461,8 @@ public void Open() public void Close() { + (_histories?.DetailContext as CommitChanges)?.Dispose(); + var commitMessage = _workingCopy.CommitMessage; if (!string.IsNullOrEmpty(commitMessage) && _workingCopy.InProgressContext != null) File.WriteAllText(Path.Combine(GitDir, "MERGE_MSG"), commitMessage); diff --git a/src/Views/CommitBaseInfo.axaml.cs b/src/Views/CommitBaseInfo.axaml.cs index 50809468de..5cfa85cba8 100644 --- a/src/Views/CommitBaseInfo.axaml.cs +++ b/src/Views/CommitBaseInfo.axaml.cs @@ -226,8 +226,8 @@ private void OnUserContextRequested(object sender, ContextRequestedEventArgs e) private async void OnCopyAllCommitMessage(object sender, RoutedEventArgs e) { - if (DataContext is ViewModels.CommitDetail detail) - await this.CopyTextAsync(detail.FullMessage.Message); + if (DataContext is ViewModels.CommitDetail { FullMessage: { } message }) + await this.CopyTextAsync(message.Message); e.Handled = true; } diff --git a/src/Views/CommitDetail.axaml b/src/Views/CommitDetail.axaml index b0fbf5393c..fcfe4e15c9 100644 --- a/src/Views/CommitDetail.axaml +++ b/src/Views/CommitDetail.axaml @@ -23,65 +23,7 @@ Offset="{Binding ScrollOffset, Mode=TwoWay}" HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto" IsVisible="{Binding #ThisControl.IsDetailsPanelExpanded, Mode=OneWay}"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + diff --git a/src/Views/CommitDetail.axaml.cs b/src/Views/CommitDetail.axaml.cs index dd175eadd8..4858e37e3f 100644 --- a/src/Views/CommitDetail.axaml.cs +++ b/src/Views/CommitDetail.axaml.cs @@ -1,11 +1,6 @@ -using System; -using System.Collections.Generic; -using System.IO; - using Avalonia; using Avalonia.Controls; using Avalonia.Input; -using Avalonia.Platform.Storage; using Avalonia.VisualTree; namespace SourceGit.Views @@ -31,270 +26,7 @@ public CommitDetail() public ContextMenu CreateChangeContextMenu(Models.Change change) { - if (DataContext is not ViewModels.CommitDetail { Repository: { } repo, Commit: { } commit } vm) - return null; - - var openWith = new MenuItem(); - openWith.Header = App.Text("Open"); - openWith.Icon = this.CreateMenuIcon("Icons.OpenWith"); - openWith.IsEnabled = change.Index != Models.ChangeState.Deleted; - if (openWith.IsEnabled) - { - var defaultEditor = new MenuItem(); - defaultEditor.Header = App.Text("Open.SystemDefaultEditor"); - defaultEditor.Click += async (_, ev) => - { - await vm.OpenRevisionFileAsync(change.Path, null); - ev.Handled = true; - }; - - openWith.Items.Add(defaultEditor); - - var tools = Native.OS.ExternalTools; - if (tools.Count > 0) - { - openWith.Items.Add(new MenuItem() { Header = "-" }); - - for (var i = 0; i < tools.Count; i++) - { - var tool = tools[i]; - var item = new MenuItem(); - item.Header = tool.Name; - item.Icon = new Image { Width = 16, Height = 16, Source = tool.IconImage }; - item.Click += async (_, ev) => - { - await vm.OpenRevisionFileAsync(change.Path, tool); - ev.Handled = true; - }; - - openWith.Items.Add(item); - } - } - } - - var openWithMerger = new MenuItem(); - openWithMerger.Header = App.Text("OpenInExternalMergeTool"); - openWithMerger.Icon = this.CreateMenuIcon("Icons.OpenWith"); - openWithMerger.Tag = OperatingSystem.IsMacOS() ? "⌘+⇧+D" : "Ctrl+Shift+D"; - openWithMerger.Click += (_, ev) => - { - vm.OpenChangeInMergeTool(change); - ev.Handled = true; - }; - - var fullPath = Native.OS.GetAbsPath(repo.FullPath, change.Path); - var explore = new MenuItem(); - explore.Header = App.Text("RevealFile"); - explore.Icon = this.CreateMenuIcon("Icons.Explore"); - explore.IsEnabled = File.Exists(fullPath); - explore.Click += (_, ev) => - { - Native.OS.OpenInFileManager(fullPath); - ev.Handled = true; - }; - - var history = new MenuItem(); - history.Header = App.Text("FileHistory"); - history.Icon = this.CreateMenuIcon("Icons.Histories"); - history.Click += (_, ev) => - { - this.ShowWindow(new ViewModels.FileHistories(repo.FullPath, change.Path, commit.SHA)); - ev.Handled = true; - }; - - var blame = new MenuItem(); - blame.Header = App.Text("Blame"); - blame.Icon = this.CreateMenuIcon("Icons.Blame"); - blame.IsEnabled = change.Index != Models.ChangeState.Deleted; - blame.Click += (_, ev) => - { - this.ShowWindow(new ViewModels.Blame(repo.FullPath, change.Path, commit)); - ev.Handled = true; - }; - - var patch = new MenuItem(); - patch.Header = App.Text("FileCM.SaveAsPatch"); - patch.Icon = this.CreateMenuIcon("Icons.Save"); - patch.Click += async (_, e) => - { - var storageProvider = TopLevel.GetTopLevel(this)?.StorageProvider; - if (storageProvider == null) - return; - - var options = new FilePickerSaveOptions(); - options.Title = App.Text("FileCM.SaveAsPatch"); - options.DefaultExtension = ".patch"; - options.FileTypeChoices = [new FilePickerFileType("Patch File") { Patterns = ["*.patch"] }]; - - try - { - var storageFile = await storageProvider.SaveFilePickerAsync(options); - if (storageFile != null) - { - var saveTo = storageFile.Path.LocalPath; - await vm.SaveChangesAsPatchAsync([change], saveTo); - } - } - catch (Exception exception) - { - repo.SendNotification($"Failed to save as patch: {exception.Message}", true); - } - - e.Handled = true; - }; - - var menu = new ContextMenu(); - menu.Items.Add(openWith); - menu.Items.Add(openWithMerger); - menu.Items.Add(explore); - menu.Items.Add(new MenuItem { Header = "-" }); - menu.Items.Add(history); - menu.Items.Add(blame); - menu.Items.Add(patch); - menu.Items.Add(new MenuItem { Header = "-" }); - - if (!repo.IsBare) - { - var resetToThisRevision = new MenuItem(); - resetToThisRevision.Header = App.Text("ChangeCM.CheckoutThisRevision"); - resetToThisRevision.Icon = this.CreateMenuIcon("Icons.File.Checkout"); - resetToThisRevision.Click += async (_, ev) => - { - await vm.ResetToThisRevisionAsync(change); - ev.Handled = true; - }; - - var resetToFirstParent = new MenuItem(); - resetToFirstParent.Header = App.Text("ChangeCM.CheckoutFirstParentRevision"); - resetToFirstParent.Icon = this.CreateMenuIcon("Icons.File.Checkout"); - resetToFirstParent.IsEnabled = commit.Parents.Count > 0; - resetToFirstParent.Click += async (_, ev) => - { - await vm.ResetToParentRevisionAsync(change); - ev.Handled = true; - }; - - menu.Items.Add(resetToThisRevision); - menu.Items.Add(resetToFirstParent); - menu.Items.Add(new MenuItem { Header = "-" }); - - if (repo.Remotes.Count > 0 && File.Exists(fullPath) && repo.IsLFSEnabled()) - { - var lfs = new MenuItem(); - lfs.Header = App.Text("GitLFS"); - lfs.Icon = this.CreateMenuIcon("Icons.LFS"); - - var lfsLock = new MenuItem(); - lfsLock.Header = App.Text("GitLFS.Locks.Lock"); - lfsLock.Icon = this.CreateMenuIcon("Icons.Lock"); - if (repo.Remotes.Count == 1) - { - lfsLock.Click += async (_, e) => - { - await repo.LockLFSFileAsync(repo.Remotes[0].Name, change.Path); - e.Handled = true; - }; - } - else - { - foreach (var remote in repo.Remotes) - { - var remoteName = remote.Name; - var lockRemote = new MenuItem(); - lockRemote.Header = remoteName; - lockRemote.Click += async (_, e) => - { - await repo.LockLFSFileAsync(remoteName, change.Path); - e.Handled = true; - }; - lfsLock.Items.Add(lockRemote); - } - } - lfs.Items.Add(lfsLock); - - var lfsUnlock = new MenuItem(); - lfsUnlock.Header = App.Text("GitLFS.Locks.Unlock"); - lfsUnlock.Icon = this.CreateMenuIcon("Icons.Unlock"); - if (repo.Remotes.Count == 1) - { - lfsUnlock.Click += async (_, e) => - { - await repo.UnlockLFSFileAsync(repo.Remotes[0].Name, change.Path, false, true); - e.Handled = true; - }; - } - else - { - foreach (var remote in repo.Remotes) - { - var remoteName = remote.Name; - var unlockRemote = new MenuItem(); - unlockRemote.Header = remoteName; - unlockRemote.Click += async (_, e) => - { - await repo.UnlockLFSFileAsync(remoteName, change.Path, false, true); - e.Handled = true; - }; - lfsUnlock.Items.Add(unlockRemote); - } - } - lfs.Items.Add(lfsUnlock); - - menu.Items.Add(lfs); - menu.Items.Add(new MenuItem() { Header = "-" }); - } - } - - var actions = repo.GetCustomActions(Models.CustomActionScope.File); - if (actions.Count > 0) - { - var target = new Models.CustomActionTargetFile(change.Path, vm.Commit); - var custom = new MenuItem(); - custom.Header = App.Text("FileCM.CustomAction"); - custom.Icon = this.CreateMenuIcon("Icons.Action"); - - foreach (var action in actions) - { - var (dup, label) = action; - var item = new MenuItem(); - item.Icon = this.CreateMenuIcon("Icons.Action"); - item.Header = label; - item.Click += async (_, e) => - { - await repo.ExecCustomActionAsync(dup, target); - e.Handled = true; - }; - - custom.Items.Add(item); - } - - menu.Items.Add(custom); - menu.Items.Add(new MenuItem() { Header = "-" }); - } - - var copyPath = new MenuItem(); - copyPath.Header = App.Text("CopyPath"); - copyPath.Icon = this.CreateMenuIcon("Icons.Copy"); - copyPath.Tag = OperatingSystem.IsMacOS() ? "⌘+C" : "Ctrl+C"; - copyPath.Click += async (_, ev) => - { - await this.CopyTextAsync(change.Path); - ev.Handled = true; - }; - - var copyFullPath = new MenuItem(); - copyFullPath.Header = App.Text("CopyFullPath"); - copyFullPath.Icon = this.CreateMenuIcon("Icons.Copy"); - copyFullPath.Tag = OperatingSystem.IsMacOS() ? "⌘+⇧+C" : "Ctrl+Shift+C"; - copyFullPath.Click += async (_, e) => - { - await this.CopyTextAsync(fullPath); - e.Handled = true; - }; - - menu.Items.Add(copyPath); - menu.Items.Add(copyFullPath); - return menu; + return CommitInformation.CreateChangeContextMenu(this, change); } private void OnTabHeaderPointerPressed(object sender, PointerPressedEventArgs e) @@ -310,53 +42,6 @@ private void OnTabHeaderPointerPressed(object sender, PointerPressedEventArgs e) vm.IsCollapseDetails = false; } - private async void OnCommitListKeyDown(object sender, KeyEventArgs e) - { - if (DataContext is not ViewModels.CommitDetail vm) - return; - - if (sender is not ListBox { SelectedItem: Models.Change change }) - return; - - if (e.Key == Key.C && - e.KeyModifiers.HasFlag(OperatingSystem.IsMacOS() ? KeyModifiers.Meta : KeyModifiers.Control)) - { - if (e.KeyModifiers.HasFlag(KeyModifiers.Shift)) - await this.CopyTextAsync(vm.GetAbsPath(change.Path)); - else - await this.CopyTextAsync(change.Path); - - e.Handled = true; - return; - } - - if (e.Key == Key.D && - e.KeyModifiers.HasFlag(OperatingSystem.IsMacOS() ? KeyModifiers.Meta : KeyModifiers.Control) && - e.KeyModifiers.HasFlag(KeyModifiers.Shift)) - { - vm.OpenChangeInMergeTool(change); - e.Handled = true; - } - } - - private void OnChangeDoubleTapped(object sender, TappedEventArgs e) - { - if (DataContext is ViewModels.CommitDetail detail && sender is Grid { DataContext: Models.Change change }) - { - detail.ChangeSelection = new(new List { change }); - detail.ActiveTabIndex = 1; - } - - e.Handled = true; - } - - private void OnChangeContextRequested(object sender, ContextRequestedEventArgs e) - { - if (sender is Grid { DataContext: Models.Change change } grid) - CreateChangeContextMenu(change)?.Open(grid); - e.Handled = true; - } - private bool _isDetailsPanelExpanded = true; } } diff --git a/src/Views/CommitInformation.axaml b/src/Views/CommitInformation.axaml new file mode 100644 index 0000000000..3a53086c14 --- /dev/null +++ b/src/Views/CommitInformation.axaml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/SelectedCommitChanges.axaml.cs b/src/Views/SelectedCommitChanges.axaml.cs new file mode 100644 index 0000000000..efbeead1ec --- /dev/null +++ b/src/Views/SelectedCommitChanges.axaml.cs @@ -0,0 +1,91 @@ +using System; +using System.Linq; + +using Avalonia.Controls; +using Avalonia.Input; + +namespace SourceGit.Views +{ + public partial class SelectedCommitChanges : UserControl + { + public SelectedCommitChanges() + { + InitializeComponent(); + } + + private void OnChangeContextRequested(object sender, ContextRequestedEventArgs e) + { + if (DataContext is not ViewModels.CommitChanges { ChangeSelection: { Count: > 0 } selection } vm) + return; + + var menu = new ContextMenu(); + if (selection is { Count: 1, HasFolder: false }) + { + var externalDiff = new MenuItem + { + Header = App.Text("OpenInExternalMergeTool"), + Icon = this.CreateMenuIcon("Icons.OpenWith"), + Tag = OperatingSystem.IsMacOS() ? "⌘+⇧+D" : "Ctrl+Shift+D", + IsEnabled = selection.Changes[0] is Models.CommitChange change && change.BeforeMode != "160000" && change.AfterMode != "160000", + }; + externalDiff.Click += (_, ev) => + { + vm.OpenChangeWithExternalDiffTool(selection.Changes[0]); + ev.Handled = true; + }; + menu.Items.Add(externalDiff); + } + + var copyPath = new MenuItem + { + Header = App.Text("CopyPath"), + Icon = this.CreateMenuIcon("Icons.Copy"), + Tag = OperatingSystem.IsMacOS() ? "⌘+C" : "Ctrl+C", + }; + copyPath.Click += async (_, ev) => + { + await this.CopyTextAsync(GetSelectedPaths(selection)); + ev.Handled = true; + }; + menu.Items.Add(copyPath); + menu.Open(sender as Control); + e.Handled = true; + } + + private void OnKeyDown(object sender, KeyEventArgs e) + { + var commandKey = OperatingSystem.IsMacOS() ? KeyModifiers.Meta : KeyModifiers.Control; + if (e.Key == Key.F && e.KeyModifiers == commandKey) + { + SelectedCommitChangesSearchBox.Focus(); + e.Handled = true; + } + } + + private async void OnChangeCollectionViewKeyDown(object sender, KeyEventArgs e) + { + if (DataContext is not ViewModels.CommitChanges { ChangeSelection: { Count: > 0 } selection } vm) + return; + + var commandKey = OperatingSystem.IsMacOS() ? KeyModifiers.Meta : KeyModifiers.Control; + if (e.Key == Key.C && e.KeyModifiers == commandKey) + { + await this.CopyTextAsync(GetSelectedPaths(selection)); + e.Handled = true; + } + else if (e.Key == Key.D && e.KeyModifiers == (commandKey | KeyModifiers.Shift) && + selection is { Count: 1, HasFolder: false }) + { + vm.OpenChangeWithExternalDiffTool(selection.Changes[0]); + e.Handled = true; + } + } + + private static string GetSelectedPaths(ViewModels.ChangeSelection selection) + { + return selection.IsSingleFolder + ? selection.SingleFolderPath + : string.Join(Environment.NewLine, selection.Changes.Select(change => change.Path)); + } + } +} diff --git a/tests/SelectedCommitChanges/Program.cs b/tests/SelectedCommitChanges/Program.cs new file mode 100644 index 0000000000..faf9a019da --- /dev/null +++ b/tests/SelectedCommitChanges/Program.cs @@ -0,0 +1,732 @@ +using System.Diagnostics; +using System.Xml.Linq; +using SourceGit.Commands; +using SourceGit.Models; + +var tests = new (string Name, Func Run)[] +{ + ("root commit and immutable endpoints", Root), + ("contiguous modifications", Contiguous), + ("reverted content retains an empty-diff entry", RevertedContent), + ("queries preserve refs, index, worktree and objects", ReadOnlyQuery), + ("non-contiguous selected paths and skipped same-file edits", NonContiguous), + ("rename chain", RenameChain), + ("add then delete cancels", AddDelete), + ("delete then add retains two changes", DeleteAdd), + ("modify then delete", ModifyDelete), + ("rename then delete", RenameDelete), + ("rename then old path recreated", RenameRecreate), + ("combined merge excludes unchanged-parent paths", Merge), + ("conflict-resolved merge uses first-parent endpoints", ConflictMerge), + ("added, deleted and renamed diff arguments execute", DiffArguments), + ("literal wildcard and magic-looking pathspecs", LiteralPaths), + ("SHA-256 addition and deletion diff endpoints", Sha256), + ("submodule changes ignore user suppression config", Submodules), + ("NUL-safe tabs, newlines, Unicode and quotes", OddPaths), + ("binary changes and executable modes", BinaryAndMode), + ("same-path aggregate respects binary Git attributes", BinaryAttributes), + ("symlink type changes", TypeChange), + ("add-rename-delete cancels", AddRenameDelete), + ("malformed raw diff is rejected", Malformed), + ("pre-cancelled selection launches no process", PreCancelled), + ("in-flight cancellation terminates process", InFlightCancellation), + ("Git failures are reported rather than empty success", Failure), + ("actual XAML wires enabled detail tabs and explicit comparison", DetailTabWiring), + ("VM selected commits and all three detail tabs", ViewModelTabs), + ("VM rapid revision navigation and disposal", ViewModelRevisionNavigation), + ("VM uses latest filter and clears hidden diff", ViewModelFilter), + ("VM ignores disposed selection queued completion", ViewModelStale), + ("VM errors and disposed error suppression", ViewModelError), +}; +var failures = 0; +foreach (var test in tests) +{ + try + { await test.Run(); Console.WriteLine($"PASS {test.Name}"); } + catch (Exception ex) { failures++; Console.Error.WriteLine($"FAIL {test.Name}: {ex}"); } +} +Console.WriteLine($"{tests.Length - failures}/{tests.Length} passed"); +return failures == 0 ? 0 : 1; + +static void Check(bool condition, string message) +{ + if (!condition) + throw new Exception(message); +} +static CommitChange One(List changes) +{ + Check(changes.Count == 1, $"Expected 1 change, got {changes.Count}"); + return changes[0]; +} +static async Task Root() +{ + using var r = new Repo(); + r.Write("first", "hello\n"); + var root = r.Commit(); + var c = One(await r.Query(root)); + Check(c.Index == ChangeState.Added && string.IsNullOrEmpty(c.BeforeRevision) && c.AfterRevision == root.SHA && c.AfterPath == "first", "Root addition endpoints"); +} +static async Task Contiguous() +{ + using var r = new Repo(); + r.Write("f", "one\n"); + var root = r.Commit(); + r.Write("f", "two\n"); + var a = r.Commit(); + r.Write("f", "three\n"); + var b = r.Commit(); + var c = One(await r.Query(b, a)); + Check(c.BeforeRevision == a.Parents[0] && c.AfterRevision == b.SHA && c.Index == ChangeState.Modified, "Oldest-before/newest-after order"); + Check(r.Read(c.BeforeRevision, c.BeforePath) == "one\n" && r.Read(c.AfterRevision, c.AfterPath) == "three\n", "Aggregated endpoint content"); +} +static async Task RevertedContent() +{ + using var r = new Repo(); + r.Write("f", "original\n"); + r.Commit(); + r.Write("f", "changed\n"); + var first = r.Commit(); + r.Write("f", "original\n"); + var last = r.Commit(); + var change = One(await r.Query(last, first)); + Check(change.Index == ChangeState.Modified && change.BeforeObject == change.AfterObject, + "A reverted file remains a selected changed path with identical endpoints"); + Check(r.Diff(new DiffOption(change)).Length == 0, "Reverted endpoints have an empty content diff"); +} +static async Task ReadOnlyQuery() +{ + using var r = new Repo(); + r.Write("f", "original\n"); + var first = r.Commit(); + r.Write("f", "committed\n"); + var last = r.Commit(); + r.Write("f", "staged\n"); + r.Git("add", "f"); + r.Write("f", "unstaged\n"); + r.Write("untracked", "untracked\n"); + var head = r.Git("rev-parse", "HEAD"); + var refs = r.Git("show-ref"); + var index = File.ReadAllBytes(System.IO.Path.Combine(r.Path, ".git", "index")); + var objects = r.Git("count-objects", "-v"); + var change = One(await r.Query(last, first)); + Check(r.Diff(new DiffOption(change)).Contains("+committed"), "Historical diff ignores dirty worktree content"); + Check(r.Git("rev-parse", "HEAD") == head && r.Git("show-ref") == refs, "Query changed HEAD or refs"); + Check(File.ReadAllBytes(System.IO.Path.Combine(r.Path, ".git", "index")).SequenceEqual(index), "Query changed the index"); + Check(File.ReadAllText(System.IO.Path.Combine(r.Path, "f")) == "unstaged\n" && + File.ReadAllText(System.IO.Path.Combine(r.Path, "untracked")) == "untracked\n", "Query changed working files"); + Check(r.Git("count-objects", "-v") == objects, "Query wrote temporary objects"); +} +static async Task NonContiguous() +{ + using var r = new Repo(); + r.Write("f", "base\n"); + r.Commit(); + r.Write("f", "base\nselected-a\n"); + var a = r.Commit(); + r.Write("f", "base\nselected-a\nskipped\n"); + r.Write("unselected-only", "excluded\n"); + r.Commit(); + r.Write("f", "base\nselected-a\nskipped\nselected-b\n"); + var b = r.Commit(); + var c = One(await r.Query(b, a)); + Check(c.Path == "f" && r.Read(c.AfterRevision, c.AfterPath).Contains("skipped"), "Zipped endpoint semantics, not replay or range file list"); +} +static async Task RenameChain() +{ + using var r = new Repo(); + r.Write("a", "stable contents\n"); + r.Commit(); + r.Git("mv", "a", "b"); + var a = r.Commit(); + r.Git("mv", "b", "c"); + var b = r.Commit(); + var c = One(await r.Query(b, a)); + Check(c.Index == ChangeState.Renamed && c.BeforePath == "a" && c.AfterPath == "c" && c.OriginalPath == "a", "Rename chain endpoints"); +} +static async Task AddDelete() +{ + using var r = new Repo(); + r.Write("anchor", "a"); + r.Commit(); + r.Write("f", "added"); + var a = r.Commit(); + r.Delete("f"); + var b = r.Commit(); + Check((await r.Query(b, a)).Count == 0, "Add/delete should cancel"); +} +static async Task DeleteAdd() +{ + using var r = new Repo(); + r.Write("f", "old"); + r.Commit(); + r.Delete("f"); + var a = r.Commit(); + r.Write("f", "new"); + var b = r.Commit(); + var changes = await r.Query(b, a); + Check(changes.Count == 2 && changes[0].Index == ChangeState.Deleted && changes[1].Index == ChangeState.Added, "Delete/add must not merge distinct lifetimes"); +} +static async Task ModifyDelete() +{ + using var r = new Repo(); + r.Write("f", "old"); + r.Commit(); + r.Write("f", "new"); + var a = r.Commit(); + r.Delete("f"); + var b = r.Commit(); + var c = One(await r.Query(b, a)); + Check(c.Index == ChangeState.Deleted && c.BeforeRevision == a.Parents[0] && string.IsNullOrEmpty(c.AfterRevision), "Modify/delete endpoints"); +} +static async Task RenameDelete() +{ + using var r = new Repo(); + r.Write("a", "old"); + r.Commit(); + r.Git("mv", "a", "b"); + var a = r.Commit(); + r.Delete("b"); + var b = r.Commit(); + var c = One(await r.Query(b, a)); + Check(c.Index == ChangeState.Deleted && c.Path == "a", "Rename/delete keeps original path"); +} +static async Task RenameRecreate() +{ + using var r = new Repo(); + r.Write("a", "stable"); + r.Commit(); + r.Git("mv", "a", "b"); + var a = r.Commit(); + r.Write("a", "recreated"); + var b = r.Commit(); + r.Write("b", "changed"); + var c = r.Commit(); + var changes = await r.Query(c, b, a); + Check(changes.Count == 2 && changes.Any(x => x.Index == ChangeState.Added && x.Path == "a") && changes.Any(x => x.Index == ChangeState.Renamed && x.Path == "b"), "Do not confuse source path re-use with rename destination"); +} +static async Task Merge() +{ + using var r = new Repo(); + r.Write("base", "base"); + r.Commit(); + r.Git("checkout", "-b", "side"); + r.Write("side", "side"); + r.Commit(); + r.Git("checkout", "main"); + r.Write("main", "main"); + r.Commit(); + r.Git("merge", "--no-ff", "side", "-m", "merge"); + var merge = r.Head(); + Check((await r.Query(merge)).Count == 0, "Combined merge excludes paths equal to either parent"); +} +static async Task ConflictMerge() +{ + using var r = new Repo(); + r.Write("f", "base\n"); + r.Commit(); + r.Git("checkout", "-b", "side"); + r.Write("f", "side\n"); + r.Commit(); + r.Git("checkout", "main"); + r.Write("f", "main\n"); + var firstParent = r.Commit(); + try + { r.Git("merge", "--no-ff", "side", "-m", "merge"); throw new Exception("Expected fixture conflict"); } + catch (Exception ex) when (ex.Message.Contains("CONFLICT")) { } + r.Write("f", "resolved\n"); + var merge = r.Commit(); + var c = One(await r.Query(merge)); + Check(c.Path == "f" && c.BeforeRevision == firstParent.SHA && c.AfterRevision == merge.SHA, "Combined merge retains resolution against first parent"); +} +static async Task DiffArguments() +{ + using var r = new Repo(); + r.Write("odd name", "original\n"); + var added = r.Commit(); + var add = One(await r.Query(added)); + var option = new DiffOption(add); + Check(!option.ToString().Contains(new string('0', 40)) && option.ToString().StartsWith(EmptyTreeHash.Guess(added.SHA)), "Addition uses empty tree, not zero blob"); + Check(r.Diff(option).Contains("+original"), "Addition diff executes"); + r.Git("mv", "odd name", "renamed"); + var renamed = r.Commit(); + var rename = One(await r.Query(renamed)); + option = new DiffOption(rename); + Check(option.ToString() == $"{rename.BeforeObject} {rename.AfterObject}", "Rename compares exact blob endpoints"); + Check(r.Diff(option).Length == 0, "Pure rename has no content changes"); + r.Delete("renamed"); + var deleted = r.Commit(); + var delete = One(await r.Query(deleted)); + option = new DiffOption(delete); + Check(option.ToString().Contains(EmptyTreeHash.Guess(deleted.SHA)) && !option.ToString().Contains(new string('0', 40)), "Deletion uses correct hash-format empty tree"); + Check(r.Diff(option).Contains("-original"), "Deletion diff executes"); +} +static async Task LiteralPaths() +{ + if (OperatingSystem.IsWindows()) + return; // These filenames are not valid on Windows. + using var r = new Repo(); + string[] names = ["*.txt", ":(glob)*", "unrelated.txt"]; + foreach (var name in names) + r.Write(name, "content-for-" + name + "\n"); + var added = r.Commit(); + var changes = await r.Query(added); + foreach (var name in names.Take(2)) + { + var patch = r.Diff(new DiffOption(changes.Single(c => c.Path == name))); + Check(patch.Contains("+content-for-" + name) && !patch.Contains("content-for-unrelated.txt"), "Literal addition path leaked another file"); + } + foreach (var name in names) + r.Delete(name); + var deleted = r.Commit(); + changes = await r.Query(deleted); + foreach (var name in names.Take(2)) + { + var patch = r.Diff(new DiffOption(changes.Single(c => c.Path == name))); + Check(patch.Contains("-content-for-" + name) && !patch.Contains("content-for-unrelated.txt"), "Literal deletion path leaked another file"); + } +} +static async Task Sha256() +{ + using var r = new Repo(true); + r.Write("f", "sha256\n"); + var added = r.Commit(); + var c = One(await r.Query(added)); + Check(added.SHA.Length == 64 && r.Diff(new DiffOption(c)).Contains("+sha256"), "SHA256 root diff"); + r.Delete("f"); + var deleted = r.Commit(); + c = One(await r.Query(deleted)); + Check(r.Diff(new DiffOption(c)).Contains("-sha256"), "SHA256 delete diff"); +} +static async Task Submodules() +{ + using var r = new Repo(); + r.Write("anchor", "one"); + var target = r.Commit(); + r.Git("update-index", "--add", "--cacheinfo", "160000," + target.SHA + ",lib"); + r.Git("commit", "-m", "add gitlink"); + var added = r.Head(); + r.Git("config", "diff.ignoreSubmodules", "all"); + r.Git("config", "submodule.lib.ignore", "all"); + var c = One(await r.Query(added)); + Check(c.Path == "lib" && c.AfterMode == "160000" && c.AfterObject == target.SHA, "Submodule hidden by config"); +} +static async Task OddPaths() +{ + using var r = new Repo(); + var paths = new List { "space name", "中文.txt", "--leading-option" }; + if (!OperatingSystem.IsWindows()) + paths.AddRange(["tab\tname", "line\nname", "quote\"name", "trailing\\", "back\\\"quote", "a*", "abcdef", ":(glob)*"]); + foreach (var p in paths) + r.Write(p, "contents " + p); + var root = r.Commit(); + var changes = await r.Query(root); + Check(changes.Select(c => c.Path).ToHashSet().SetEquals(paths), "NUL parsing must preserve exact paths"); + foreach (var change in changes) + { + var diff = r.Diff(new DiffOption(change)); + Check(diff.Contains("+contents " + change.Path.Replace("\n", "\n+")), "Added odd path contents missing: " + change.Path); + Check(diff.Split("diff --git").Length == 2, "Added odd path diff included other files: " + change.Path); + } + + if (!OperatingSystem.IsWindows()) + { + r.Git("mv", "--", "tab\tname", "renamed\n中文"); + var rename = r.Commit(); + var c = One(await r.Query(rename)); + Check(c.BeforePath == "tab\tname" && c.AfterPath == "renamed\n中文", "NUL rename paths"); + // Restore the path so deletion assertions use the original file contents. + r.Git("mv", "--", "renamed\n中文", "tab\tname"); + r.Commit(); + } + + foreach (var p in paths) + r.Delete(p); + var deleted = r.Commit(); + foreach (var change in await r.Query(deleted)) + { + var diff = r.Diff(new DiffOption(change)); + Check(diff.Contains("-contents " + change.Path.Replace("\n", "\n-")), "Deleted odd path contents missing: " + change.Path); + Check(diff.Split("diff --git").Length == 2, "Deleted odd path diff included other files: " + change.Path); + } +} +static async Task BinaryAndMode() +{ + using var r = new Repo(); + r.Write("binary", "one\0two"); + r.Commit(); + r.Write("binary", "one\0three"); + var binary = r.Commit(); + var c = One(await r.Query(binary)); + Check(c.BeforeObject != c.AfterObject && c.Index == ChangeState.Modified, "Binary object endpoints"); + r.Git("update-index", "--chmod=+x", "binary"); + r.Git("commit", "-m", "executable mode"); + var mode = r.Head(); + c = One(await r.Query(mode)); + Check(c.BeforeMode == "100644" && c.AfterMode == "100755" && c.BeforeObject == c.AfterObject, "Mode-only change retained"); +} +static async Task BinaryAttributes() +{ + using var r = new Repo(); + r.Write(".gitattributes", "*.dat -diff\n"); + r.Write("file.dat", "base ASCII contents\n"); + r.Commit(); + r.Write("file.dat", "first selected ASCII contents\n"); + var first = r.Commit(); + r.Write("file.dat", "skipped ASCII contents\n"); + r.Commit(); + r.Write("file.dat", "last selected ASCII contents\n"); + var last = r.Commit(); + var change = One(await r.Query(last, first)); + Check(change.BeforeRevision == first.Parents[0] && change.AfterRevision == last.SHA, "Attribute fixture uses aggregate endpoints"); + var diff = r.Diff(new DiffOption(change)); + Check(diff.Contains("Binary files ") && !diff.Contains("@@"), "Same-path ASCII -diff attribute must remain binary"); + Check(diff.Split("diff --git").Length == 2, "Attribute diff must include only selected path"); +} +static async Task TypeChange() +{ + if (OperatingSystem.IsWindows()) + return; + using var r = new Repo(); + r.Write("f", "contents"); + r.Commit(); + r.Delete("f"); + File.CreateSymbolicLink(System.IO.Path.Combine(r.Path, "f"), "target"); + var typeChange = r.Commit(); + var c = One(await r.Query(typeChange)); + Check(c.Index == ChangeState.TypeChanged && c.BeforeMode == "100644" && c.AfterMode == "120000", "File/symlink type change retained"); +} +static async Task AddRenameDelete() +{ + using var r = new Repo(); + r.Write("anchor", "stable"); + r.Commit(); + r.Write("a", "contents"); + var a = r.Commit(); + r.Git("mv", "a", "b"); + var b = r.Commit(); + r.Delete("b"); + var c = r.Commit(); + Check((await r.Query(c, b, a)).Count == 0, "Add/rename/delete lifetime cancels"); +} +static Task Malformed() +{ + try + { QuerySelectedCommitChanges.Parse("not-a-header\0path\0", "before", "after"); throw new Exception("Malformed output accepted"); } + catch (InvalidDataException) { } + return Task.CompletedTask; +} +static async Task PreCancelled() +{ + using var r = new Repo(); + r.Write("f", "f"); + var c = r.Commit(); + using var cancel = new CancellationTokenSource(); + cancel.Cancel(); + var count = Command.StartInfoCount; + try + { await new QuerySelectedCommitChanges(r.Path).ReadAsync([c], cancel.Token); throw new Exception("Cancellation ignored"); } + catch (OperationCanceledException) { } + Check(Command.StartInfoCount == count, "Cancelled query started Git"); +} +static async Task InFlightCancellation() +{ + if (OperatingSystem.IsWindows()) + return; + using var r = new Repo(); + r.Write("f", "f"); + var c = r.Commit(); + Command.StartInfoOverride = () => new ProcessStartInfo("sh", "-c \"sleep 30\"") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false }; + try + { + using var cancel = new CancellationTokenSource(TimeSpan.FromMilliseconds(150)); + var timer = Stopwatch.StartNew(); + try + { await new QuerySelectedCommitChanges(r.Path).ReadAsync([c], cancel.Token); throw new Exception("Cancellation ignored"); } + catch (OperationCanceledException) { } + Check(timer.Elapsed < TimeSpan.FromSeconds(5), "Cancelled process remained alive"); + } + finally { Command.StartInfoOverride = null; } +} +static async Task Failure() +{ + using var r = new Repo(); + try + { await r.Query(new Commit { SHA = new string('1', 40) }); throw new Exception("Git failure treated as success"); } + catch (InvalidOperationException ex) { Check(ex.Message.Length > 0, "Missing Git error"); } +} + +static async Task AwaitDispatch() +{ + var timer = Stopwatch.StartNew(); + while (Avalonia.Threading.Dispatcher.UIThread.PendingCount == 0) + { + if (timer.Elapsed > TimeSpan.FromSeconds(5)) + throw new Exception("Timed out awaiting queued UI callback"); + await Task.Delay(10); + } +} +// Source-structure assertions complement (not replace) Avalonia compilation and +// runtime UI testing: VM-only tests cannot catch a disabled or unbound tab. +static Task DetailTabWiring() +{ + var root = new DirectoryInfo(AppContext.BaseDirectory); + while (root != null && !File.Exists(System.IO.Path.Combine(root.FullName, "src", "Views", "Histories.axaml"))) + root = root.Parent; + Check(root != null, "Could not locate production Histories.axaml from test output directory"); + var views = System.IO.Path.Combine(root.FullName, "src", "Views"); + var document = XDocument.Load(System.IO.Path.Combine(views, "Histories.axaml")); + XNamespace av = "https://github.com/avaloniaui"; + XNamespace v = "using:SourceGit.Views"; + var template = document.Descendants(av + "DataTemplate") + .Single(e => (string)e.Attribute("DataType") == "vm:CommitChanges"); + var tabs = template.Element(av + "TabControl"); + Check((string)tabs?.Attribute("SelectedIndex") == "{Binding ActiveTabIndex, Mode=TwoWay}", "Detail tab selection must bind to VM rather than fixed index"); + var items = tabs.Elements(av + "TabItem").ToArray(); + Check(items.Length == 3 && items.All(e => e.Attribute("IsEnabled") == null || (string)e.Attribute("IsEnabled") == "True"), "All three multi-selection detail tabs must be enabled"); + var headers = new[] { "Info", "Changes", "Files" }; + for (var i = 0; i < items.Length; i++) + { + var header = items[i].Element(av + "TabItem.Header"); + Check(header.Descendants(av + "TextBlock").Any(e => (string)e.Attribute("Text") == "{DynamicResource Text.CommitDetail." + headers[i] + "}"), "Detail tab order/header changed"); + Check(header.Descendants(av + "Border").Any(e => (string)e.Attribute("PointerPressed") == "OnTabHeaderPointerPressed"), "Each tab must preserve expand-on-header behavior"); + } + var list = items[0].Descendants(av + "ListBox").Single(); + Check((string)list.Attribute("ItemsSource") == "{Binding InformationDetails}" && + (string)list.Attribute("SelectedItem") == "{Binding SelectedInformationDetail, Mode=TwoWay}", "Information cards choose the file revision through their detail context"); + Check(list.Descendants(av + "VirtualizingStackPanel").Any(), "Cards must virtualize to defer offscreen information queries"); + var card = list.Descendants(v + "CommitInformation").Single(); + Check((string)card.Attribute("OpenChangesInNewWindow") == "True" && + (string)card.Parent.Attribute("BorderThickness") == "1", "Cards reuse full information within a visible border and open individual diffs separately"); + var single = XDocument.Load(System.IO.Path.Combine(views, "CommitDetail.axaml")); + Check(single.Descendants(v + "CommitInformation").Count() == 1, "Single and multi commits must share the exact Information view"); + var info = XDocument.Load(System.IO.Path.Combine(views, "CommitInformation.axaml")); + var baseInfo = info.Descendants(v + "CommitBaseInfo").Single(); + foreach (var field in new[] { "FullMessage", "SignInfo", "WebLinks" }) + Check((string)baseInfo.Attribute(field) == "{Binding " + field + "}", "Full single-commit information missing: " + field); + Check(info.Descendants(av + "ListBox").Any(e => ((string)e.Attribute("ItemsSource"))?.Contains("Top100Changes") == true), "Full information includes the original changed-file preview"); + Check(items[1].Descendants(v + "SelectedCommitChanges").Count() == 1, "Middle tab must retain aggregate changed-file viewer"); + var chooser = items[2].Descendants(av + "ComboBox").Single(); + Check((string)chooser.Attribute("ItemsSource") == "{Binding Commits}" && + (string)chooser.Attribute("SelectedItem") == "{Binding SelectedCommit, Mode=TwoWay}", "Files chooser must share selected commit with Information"); + var content = items[2].Descendants(av + "ContentControl").Single(); + Check((string)content.Attribute("Content") == "{Binding RevisionDetail}" && + content.Descendants(av + "DataTemplate").Any(e => (string)e.Attribute("DataType") == "vm:CommitDetail" && e.Element(v + "RevisionFiles") != null), "Files must reuse the single-commit revision viewer with its own detail VM"); + Check(info.Descendants(av + "Button").Any(e => (string)e.Attribute("Click") == "OnOpenAllChanges"), "Truncated cards must expose their own complete Changes list without replacing the aggregate"); + var detailCode = File.ReadAllText(System.IO.Path.Combine(views, "CommitDetail.axaml.cs")); + Check(detailCode.Contains("CommitInformation.CreateChangeContextMenu(this, change)"), "Changes-tab menus must use the attached detail control, not its detached Information tab"); + var infoCode = File.ReadAllText(System.IO.Path.Combine(views, "CommitInformation.axaml.cs")); + Check(infoCode.Contains("TopLevel.GetTopLevel(owner)") && infoCode.Contains("owner.CopyTextAsync"), "Shared menus preserve active clipboard and file picker owner"); + var code = File.ReadAllText(System.IO.Path.Combine(views, "Histories.axaml.cs")); + Check(System.Text.RegularExpressions.Regex.IsMatch(code, + @"if \(selected\.Count == 2\)\s*\{\s*var compare = new MenuItem.*?SelectedCommitChanges\.CompareRevisions.*?compare\.Click.*?histories\.CompareSelectedRevisions\(\).*?menu\.Items\.Add\(compare\)", + System.Text.RegularExpressions.RegexOptions.Singleline), "Explicit two-revision comparison must remain a separate two-selection context action"); + return Task.CompletedTask; +} +static async Task ViewModelTabs() +{ + using var r = new Repo(); + r.Write("first", "one"); + var first = r.Commit(); + r.Write("skipped", "skip"); + var skipped = r.Commit(); + r.Write("last", "two"); + var last = r.Commit(); + var dispatcher = Avalonia.Threading.Dispatcher.UIThread; + dispatcher.Hold = true; + try + { + var selection = new List { last, first }; + using var vm = new SourceGit.ViewModels.CommitChanges(new() { FullPath = r.Path }, selection); + selection.Clear(); + Check(vm.Commits.SequenceEqual(new[] { last, first }) && vm.CommitCount == 2, "Information lists precisely the selected commits in history order using a defensive snapshot"); + Check(!vm.Commits.Contains(skipped), "Information must not include skipped commits"); + Check(vm.SelectedCommit == last && vm.ActiveTabIndex == 1 && vm.RevisionDetail == null, "Default Changes tab does not eagerly load Files"); + vm.ActiveTabIndex = 0; + Check(vm.ActiveTabIndex == 0 && vm.RevisionDetail == null, "Information tab is reachable without loading Files"); + Check(vm.InformationDetails.Select(d => d.Commit).SequenceEqual(vm.Commits), "Every selected commit has one full-information context in history order"); + Check(vm.InformationDetails.All(d => d.DefersInformationLoading && d.ActiveTabIndex == 0), "Offscreen cards defer heavy loading and never create diff contexts"); + Check(vm.InformationDetails.Select(d => d.InformationLoadGate).Distinct().Count() == 1 && + vm.InformationDetails[0].InformationLoadGate.CurrentCount == 2, "Card queries share a bounded concurrency gate"); + vm.SelectedInformationDetail = vm.InformationDetails[1]; + Check(vm.SelectedCommit == first && vm.RevisionDetail == null, "Selecting information card chooses revision while keeping Files lazy"); + vm.SelectedInformationDetail = null; + vm.SelectedInformationDetail = new SourceGit.ViewModels.CommitDetail(new(), null) { Commit = skipped }; + Check(vm.SelectedCommit == first, "Null and outside-card selection cannot replace the chosen revision"); + vm.ActiveTabIndex = 2; + var detail = vm.RevisionDetail; + Check(vm.ActiveTabIndex == 2 && detail?.Commit == first, "Files uses the selected information row revision"); + vm.ActiveTabIndex = 1; + Check(vm.ActiveTabIndex == 1 && vm.RevisionDetail == detail, "Changes tab preserves current file revision"); + vm.ActiveTabIndex = -1; + vm.ActiveTabIndex = 3; + Check(vm.ActiveTabIndex == 1, "Invalid tab indexes do not hide all content"); + await AwaitDispatch(); + dispatcher.Drain(); + Check(vm.TotalChanges == 2 && vm.VisibleChanges.Select(c => c.Path).Order().SequenceEqual(new[] { "first", "last" }), "Browsing a selected commit does not change the aggregate summary"); + Check(vm.Commits.SequenceEqual(new[] { last, first }), "Tab navigation preserves exact selected commit list"); + } + finally { dispatcher.Hold = false; dispatcher.Drain(); } +} +static async Task ViewModelRevisionNavigation() +{ + using var r = new Repo(); + r.Write("first", "one"); + var first = r.Commit(); + r.Write("last", "two"); + var last = r.Commit(); + var dispatcher = Avalonia.Threading.Dispatcher.UIThread; + dispatcher.Hold = true; + try + { + using var vm = new SourceGit.ViewModels.CommitChanges(new() { FullPath = r.Path }, [last, first]); + vm.ActiveTabIndex = 2; + Check(vm.RevisionDetail?.Commit == last, "Files defaults to topmost selected history commit"); + for (var i = 0; i < 10; i++) + { + var previous = vm.RevisionDetail; + var selected = i % 2 == 0 ? first : last; + vm.SelectedCommit = selected; + Check(previous.IsDisposed && vm.RevisionDetail != previous && vm.RevisionDetail.Commit == selected, "Revision navigation disposes old detail and creates a fresh revision owner"); + var current = vm.RevisionDetail; + vm.SelectedCommit = selected; + vm.SelectedCommit = null; + vm.SelectedCommit = new Commit { SHA = new string('f', 40) }; + Check(vm.SelectedCommit == selected && vm.RevisionDetail == current && !current.IsDisposed, "Repeated, null and outside-selection row events do not replace current detail"); + vm.ActiveTabIndex = 0; + vm.ActiveTabIndex = 1; + vm.ActiveTabIndex = 2; + Check(vm.RevisionDetail == current, "Repeated tab navigation does not reload the chosen revision"); + } + await AwaitDispatch(); + var finalDetail = vm.RevisionDetail; + var finalSelection = vm.SelectedCommit; + vm.Dispose(); + vm.Dispose(); + dispatcher.Drain(); + Check(vm.InformationDetails.All(d => d.IsDisposed), "Disposal cancels every card's pending information queries"); + Check(finalDetail.IsDisposed && vm.TotalChanges == 0, "Disposal closes file detail and suppresses queued aggregate completion"); + vm.SelectedCommit = first; + vm.ActiveTabIndex = 0; + vm.ActiveTabIndex = 2; + Check(vm.SelectedCommit == finalSelection && (vm.RevisionDetail == null || vm.RevisionDetail == finalDetail), "Late navigation cannot resurrect a disposed detail owner"); + } + finally { dispatcher.Hold = false; dispatcher.Drain(); } +} +static async Task ViewModelFilter() +{ + using var r = new Repo(); + r.Write("first", "one"); + r.Write("second", "two"); + var commit = r.Commit(); + var dispatcher = Avalonia.Threading.Dispatcher.UIThread; + dispatcher.Hold = true; + try + { + using var vm = new SourceGit.ViewModels.CommitChanges(new() { FullPath = r.Path }, [commit]); + vm.SearchFilter = "SECOND"; + await AwaitDispatch(); + dispatcher.Drain(); + Check(!vm.IsLoading && vm.TotalChanges == 2 && vm.VisibleChanges.Count == 1 && vm.VisibleChanges[0].Path == "second", "Latest filter applies after async load"); + Check(vm.DiffContext?.Option.Path == "second", "Auto-select visible diff"); + vm.SearchFilter = "missing"; + Check(vm.VisibleChanges.Count == 0 && vm.ChangeSelection.Count == 0 && vm.DiffContext == null, "Hiding selection clears stale diff"); + vm.ClearSearchFilter(); + Check(vm.VisibleChanges.Count == 2 && vm.ChangeSelection.Count == 1, "Clearing filter restores changes"); + vm.ChangeSelection = new(vm.VisibleChanges); + Check(vm.DiffContext == null, "Multiple file selection has no stale single-file diff"); + } + finally { dispatcher.Hold = false; dispatcher.Drain(); } +} +static async Task ViewModelStale() +{ + using var r = new Repo(); + r.Write("old", "one"); + var old = r.Commit(); + r.Write("new", "two"); + var current = r.Commit(); + var dispatcher = Avalonia.Threading.Dispatcher.UIThread; + dispatcher.Hold = true; + try + { + var selection = new List { old }; + using var oldVm = new SourceGit.ViewModels.CommitChanges(new() { FullPath = r.Path }, selection); + selection.Clear(); // Constructor owns a snapshot, not the mutable UI list. + await AwaitDispatch(); + oldVm.Dispose(); + dispatcher.Drain(); + Check(oldVm.TotalChanges == 0 && oldVm.DiffContext == null, "Disposed success callback populated stale VM"); + using var currentVm = new SourceGit.ViewModels.CommitChanges(new() { FullPath = r.Path }, [current]); + await AwaitDispatch(); + dispatcher.Drain(); + Check(currentVm.CommitCount == 1 && currentVm.VisibleChanges.Single().Path == "new", "Newest selection owns result"); + } + finally { dispatcher.Hold = false; dispatcher.Drain(); } +} +static async Task ViewModelError() +{ + using var r = new Repo(); + var invalid = new Commit { SHA = new string('1', 40) }; + var dispatcher = Avalonia.Threading.Dispatcher.UIThread; + dispatcher.Hold = true; + try + { + using var errorVm = new SourceGit.ViewModels.CommitChanges(new() { FullPath = r.Path }, [invalid]); + await AwaitDispatch(); + dispatcher.Drain(); + Check(!errorVm.IsLoading && !string.IsNullOrEmpty(errorVm.Error), "Failed query did not expose error"); + using var staleVm = new SourceGit.ViewModels.CommitChanges(new() { FullPath = r.Path }, [invalid]); + await AwaitDispatch(); + staleVm.Dispose(); + dispatcher.Drain(); + Check(string.IsNullOrEmpty(staleVm.Error), "Disposed error callback populated stale VM"); + } + finally { dispatcher.Hold = false; dispatcher.Drain(); } +} + +sealed class Repo : IDisposable +{ + public string Path { get; } = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "sourcegit-selected-tests-" + Guid.NewGuid().ToString("N")); + public Repo(bool sha256 = false) + { + Directory.CreateDirectory(Path); + Git("init", "-b", "main", "--object-format=" + (sha256 ? "sha256" : "sha1")); + Git("config", "user.name", "Test"); + Git("config", "user.email", "test@example.invalid"); + Git("config", "commit.gpgsign", "false"); + } + public void Write(string path, string text) => File.WriteAllText(System.IO.Path.Combine(Path, path), text); + public void Delete(string path) => File.Delete(System.IO.Path.Combine(Path, path)); + public Commit Commit() { Git("add", "-A"); Git("commit", "-m", "fixture"); return Head(); } + public Commit Head() + { + var parts = Git("rev-list", "--parents", "-n", "1", "HEAD").Trim().Split(' '); + return new Commit { SHA = parts[0], Parents = parts.Skip(1).ToList() }; + } + public Task> Query(params Commit[] commits) => new QuerySelectedCommitChanges(Path).ReadAsync(commits, CancellationToken.None); + public string Read(string revision, string path) => Git("show", $"{revision}:{path}"); + public string Diff(DiffOption option) + { + var info = new ProcessStartInfo("git", "diff --no-ext-diff " + option) { WorkingDirectory = Path, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false }; + using var process = Process.Start(info); + var stdout = process.StandardOutput.ReadToEndAsync(); + var stderr = process.StandardError.ReadToEndAsync(); + process.WaitForExit(); + if (process.ExitCode != 0) + throw new Exception($"git diff {option}: {stderr.Result}{stdout.Result}"); + return stdout.Result; + } + public string Git(params string[] args) + { + var info = new ProcessStartInfo("git") { WorkingDirectory = Path, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false }; + foreach (var arg in args) + info.ArgumentList.Add(arg); + using var process = Process.Start(info); + var stdout = process.StandardOutput.ReadToEndAsync(); + var stderr = process.StandardError.ReadToEndAsync(); + process.WaitForExit(); + if (process.ExitCode != 0) + throw new Exception($"git {string.Join(' ', args)}: {stderr.Result}{stdout.Result}"); + return stdout.Result; + } + public void Dispose() => Directory.Delete(Path, true); +} diff --git a/tests/SelectedCommitChanges/README.md b/tests/SelectedCommitChanges/README.md new file mode 100644 index 0000000000..9d72e78425 --- /dev/null +++ b/tests/SelectedCommitChanges/README.md @@ -0,0 +1,40 @@ +# Selected-commit changes regression harness + +Run with the .NET 10 SDK and Git on `PATH`: + +```sh +dotnet run --project tests/SelectedCommitChanges/SelectedCommitChanges.csproj +``` + +This framework-free console harness requires no NuGet packages. It links the +production `CommitChange`, `Change`, `DiffOption`, `CommitChanges` view-model, and +`QuerySelectedCommitChanges` files rather +than reimplementing the aggregation algorithm. App process setup, minimal commit metadata, and UI infrastructure are stubbed, +avoiding Avalonia and app initialization. A queued dispatcher makes late-success +and late-error disposal races deterministic. Fixtures run real Git commands in disposable temporary +repositories; they never change the developer's worktree or Git configuration. + +Coverage includes root commits, immutable before/after revisions, contiguous and +non-contiguous selections (including skipped same-file edits), rename chains and +path reuse, file lifetimes, reverted content with empty diffs, preservation of refs, +index, working files and object database, combined merge behavior with first-parent +resolution endpoints, unusual path characters, +binary objects, executable mode-only changes, symlinks, literal pathspecs, SHA-256 +repositories, submodule configuration, malformed output, +pre-cancelled and in-flight cancellation, Git error propagation, latest-filter application, selection clearing, and +disposed view-model callback suppression. Multi-selection detail tests cover the +exact defensive commit list, all three tab indices, lazy historical-file context, +revision switching without changing the aggregate, repeated navigation, invalid +selections and disposed file-detail ownership. Card regressions verify reuse of the +single-commit Information control (including message, signature, links and file +preview), explicit virtualization and borders, deferred per-card ownership, a +shared bounded query gate, card/Files selection synchronization, and disposal of +all card contexts. Historical-file and card detail infrastructure +is stubbed in this harness; actual Avalonia interaction and file previews require +runtime UI verification. + +The fixtures supply commits in newest-first history order, as required by the +production query. Windows skips the Unix process-cancellation, symlink and +invalid-filename pathspec checks. +This is not a substitute for compiling Avalonia bindings, exercising the real +app's process configuration, or manually checking the UI's selection lifecycle. diff --git a/tests/SelectedCommitChanges/SelectedCommitChanges.csproj b/tests/SelectedCommitChanges/SelectedCommitChanges.csproj new file mode 100644 index 0000000000..7a7ebae7d0 --- /dev/null +++ b/tests/SelectedCommitChanges/SelectedCommitChanges.csproj @@ -0,0 +1,17 @@ + + + Exe + net10.0 + enable + disable + + + + + + + + + + + diff --git a/tests/SelectedCommitChanges/Stubs.cs b/tests/SelectedCommitChanges/Stubs.cs new file mode 100644 index 0000000000..9ee6e94ca6 --- /dev/null +++ b/tests/SelectedCommitChanges/Stubs.cs @@ -0,0 +1,145 @@ +using System.Diagnostics; +using System.Text; + +// Only the app infrastructure is replaced. The query, parser, zipper, and change +// types are linked directly from production and execute against real Git repos. +namespace SourceGit.Commands +{ + public class Command + { + public enum EditorType { None } + public string WorkingDirectory { get; set; } + public string Context { get; set; } + public string Args { get; set; } + public EditorType Editor { get; set; } + public static Func StartInfoOverride { get; set; } + public static int StartInfoCount { get; set; } + + protected ProcessStartInfo CreateGitStartInfo(bool redirect) + { + StartInfoCount++; + return StartInfoOverride?.Invoke() ?? new ProcessStartInfo("git", "--no-pager -c core.quotepath=off " + Args) + { + WorkingDirectory = WorkingDirectory, + UseShellExecute = false, + RedirectStandardOutput = redirect, + RedirectStandardError = redirect, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + }; + } + } +} + +namespace SourceGit.Models +{ + public class Commit + { + public string SHA { get; set; } + public List Parents { get; set; } = []; + public string FirstParentToCompare => Parents.Count > 0 ? $"{SHA}^" : "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; + } +} + +namespace SourceGit +{ + public static class StringExtensions + { + public static string Quoted(this string value) => "\"" + value.Replace("\"", "\\\"", StringComparison.Ordinal) + "\""; + } +} +namespace SourceGit.Models +{ + public class FileVersion + { + public string SHA { get; set; } + public bool HasParent { get; set; } + public string Path { get; set; } + public string OriginalPath { get; set; } + public Change Change { get; set; } + } +} + +namespace CommunityToolkit.Mvvm.ComponentModel +{ + public class ObservableObject + { + protected bool SetProperty(ref T field, T value) + { + if (EqualityComparer.Default.Equals(field, value)) + return false; + field = value; + return true; + } + protected void OnPropertyChanged(string name) { } + } +} +namespace Avalonia.Threading +{ + public sealed class Dispatcher + { + public static Dispatcher UIThread { get; } = new(); + private readonly System.Collections.Concurrent.ConcurrentQueue<(Action Action, TaskCompletionSource Completion)> _pending = new(); + public bool Hold { get; set; } + public int PendingCount => _pending.Count; + public Task InvokeAsync(Action action) + { + if (!Hold) + { action(); return Task.CompletedTask; } + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _pending.Enqueue((action, completion)); + return completion.Task; + } + public void Drain() + { + while (_pending.TryDequeue(out var pending)) + { + try + { pending.Action(); pending.Completion.SetResult(); } + catch (Exception ex) { pending.Completion.SetException(ex); } + } + } + } +} +namespace SourceGit.Commands +{ + public class DiffTool + { + public DiffTool(string repo, Models.DiffOption option) { } + public void Open() { } + } +} +namespace SourceGit.ViewModels +{ + public class Repository { public string FullPath { get; set; } } + // RevisionFiles owns this real app type. This focused harness checks the + // summary VM's ownership/lifecycle without bootstrapping Avalonia. + public class CommitDetailSharedData { public int ActiveTabIndex { get; set; } } + public class CommitDetail : IDisposable + { + public Models.Commit Commit { get; set; } + public int ActiveTabIndex { get; set; } + public bool IsDisposed { get; private set; } + public bool DefersInformationLoading { get; } + public SemaphoreSlim InformationLoadGate { get; } + public CommitDetail(Repository repo, CommitDetailSharedData sharedData, bool deferInformationLoading = false, SemaphoreSlim informationLoadGate = null) + { + DefersInformationLoading = deferInformationLoading; + InformationLoadGate = informationLoadGate; + ActiveTabIndex = sharedData?.ActiveTabIndex ?? 0; + } + public void Dispose() => IsDisposed = true; + } + public class ChangeSelection + { + public List Changes { get; } + public int Count => Changes.Count; + public bool HasFolder { get; set; } + public ChangeSelection(List changes) { Changes = changes ?? []; } + } + public class DiffContext + { + public Models.DiffOption Option { get; } + public DiffContext(string repo, Models.DiffOption option) { Option = option; } + } +} From 4ccb23837e0d7cff2c44556147a3b06bf7ff9ac3 Mon Sep 17 00:00:00 2001 From: lixiongyou Date: Mon, 26 Oct 2026 17:53:54 +0800 Subject: [PATCH 2/3] feature: compact single-child folder paths Reuse the existing compact-folders preference in change and revision trees while preserving real paths, lazy loading, and expansion state. Expose compaction as a checkbox under File System Tree without changing the original parent icons or selecting a display mode when the checkbox is toggled. Add tree-model and Avalonia menu interaction regressions. --- docs/compact-folders.md | 36 +++ src/Resources/Locales/en_US.axaml | 2 +- src/Resources/Locales/zh_CN.axaml | 2 +- src/Resources/Locales/zh_TW.axaml | 2 +- src/ViewModels/ChangeTreeNode.cs | 4 +- src/ViewModels/RevisionFileTreeNode.cs | 76 ++++- src/Views/ChangeViewModeSwitcher.axaml | 18 +- src/Views/ChangeViewModeSwitcher.axaml.cs | 23 ++ src/Views/RevisionFileTreeView.axaml | 2 + src/Views/RevisionFileTreeView.axaml.cs | 134 +++++++-- src/Views/RevisionFiles.axaml | 14 +- .../ChangeViewModeMenu.csproj | 11 + tests/ChangeViewModeMenu/Program.cs | 126 ++++++++ tests/ChangeViewModeMenu/README.md | 15 + tests/CompactFolders/CompactFolders.csproj | 15 + tests/CompactFolders/Program.cs | 275 ++++++++++++++++++ tests/CompactFolders/README.md | 25 ++ tests/CompactFolders/Stubs.cs | 18 ++ 18 files changed, 761 insertions(+), 37 deletions(-) create mode 100644 docs/compact-folders.md create mode 100644 tests/ChangeViewModeMenu/ChangeViewModeMenu.csproj create mode 100644 tests/ChangeViewModeMenu/Program.cs create mode 100644 tests/ChangeViewModeMenu/README.md create mode 100644 tests/CompactFolders/CompactFolders.csproj create mode 100644 tests/CompactFolders/Program.cs create mode 100644 tests/CompactFolders/README.md create mode 100644 tests/CompactFolders/Stubs.cs diff --git a/docs/compact-folders.md b/docs/compact-folders.md new file mode 100644 index 0000000000..df9598b721 --- /dev/null +++ b/docs/compact-folders.md @@ -0,0 +1,36 @@ +# Compact folder paths + +The **File System Tree** item in the Changes display-mode menu has a +**Compact single-child folders** submenu checkbox. Clicking the parent selects +tree mode; hovering or pressing Right Arrow opens the submenu. Toggling the +checkbox does not change the selected display mode. The three parent items +retain their original icons. +It uses the existing persisted preference (also available in Preferences > General), +so it applies consistently to working-copy changes, selected commits, comparisons +and stashes. Existing preferences and the default remain unchanged. + +The historical **Files** tab now supports the same option through its tree button +beside Search. A path such as `src/main/java/example` becomes one folder row, +`src / main / java / example`; files stay separate. Branches and folders containing +both files and folders are not merged. Full-path tooltips, keyboard navigation and +file actions continue using actual Git object paths, not display labels. + +Historical folders are loaded on demand. Compaction looks ahead only through +single-folder chains; it does not recursively enumerate branching subtrees. +Original nodes, loaded children and expansion state are retained when switching +between compact and regular views. Query results are shared for repeated loads. + +## Verification + +```sh +dotnet run --project tests/CompactFolders/CompactFolders.csproj +dotnet run --project tests/ChangeViewModeMenu -c Release -p:DisableAOT=true +dotnet build src/SourceGit.csproj -c Release -p:DisableAOT=true +``` + +Manual UI checks: toggle from each Changes display-mode menu; expand/collapse deep +chains with Left/Right keys; copy a compact folder's path; open/copy/save a file; +search for a deep file, toggle compaction, and clear search; switch revisions and +toggle repeatedly while files load. Framework-free tests exercise production tree +models. The Avalonia Headless harness checks menu pointer and keyboard input; +full historical-file navigation still requires runtime UI verification. diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index b56aa1fe06..a1a663abf8 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -696,7 +696,7 @@ GENERAL Check for updates on startup Date Format - Enable compact folders in changes tree + Compact single-child folders Language History Commits Show `LOCAL CHANGES` page by default diff --git a/src/Resources/Locales/zh_CN.axaml b/src/Resources/Locales/zh_CN.axaml index 88fa996b91..e4a9eddd4d 100644 --- a/src/Resources/Locales/zh_CN.axaml +++ b/src/Resources/Locales/zh_CN.axaml @@ -700,7 +700,7 @@ 通用配置 启动时检测软件更新 日期时间格式 - 在变更列表树中启用紧凑文件夹模式 + 合并仅含单个子文件夹的路径 显示语言 最大历史提交数 默认显示【本地更改】页 diff --git a/src/Resources/Locales/zh_TW.axaml b/src/Resources/Locales/zh_TW.axaml index ca501d1db8..672b6be78f 100644 --- a/src/Resources/Locales/zh_TW.axaml +++ b/src/Resources/Locales/zh_TW.axaml @@ -700,7 +700,7 @@ 一般設定 啟動時檢查軟體更新 日期時間格式 - 在樹狀變更目錄中啟用密集資料夾模式 + 合併僅含單個子資料夾的路徑 顯示語言 最大歷史提交數 預設顯示 [本機變更] 頁面 diff --git a/src/ViewModels/ChangeTreeNode.cs b/src/ViewModels/ChangeTreeNode.cs index c35f4fc1f0..819c5359bc 100644 --- a/src/ViewModels/ChangeTreeNode.cs +++ b/src/ViewModels/ChangeTreeNode.cs @@ -135,9 +135,9 @@ private static void Compact(ChangeTreeNode node) if (child.Change != null) return; - node.FullPath = $"{node.FullPath}/{child.DisplayName}"; + node.FullPath = child.FullPath; node.DisplayName = $"{node.DisplayName} / {child.DisplayName}"; - node.IsExpanded = child.IsExpanded; + node.IsExpanded = node.IsExpanded && child.IsExpanded; node.Children = child.Children; Compact(node); } diff --git a/src/ViewModels/RevisionFileTreeNode.cs b/src/ViewModels/RevisionFileTreeNode.cs index 2f94d40d97..93efd5adfd 100644 --- a/src/ViewModels/RevisionFileTreeNode.cs +++ b/src/ViewModels/RevisionFileTreeNode.cs @@ -1,5 +1,7 @@ +using System; using System.Collections.Generic; using System.IO; +using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; @@ -7,26 +9,82 @@ namespace SourceGit.ViewModels { public class RevisionFileTreeNode : ObservableObject { - public Models.Object Backend { get; set; } = null; + public Models.Object Backend + { + get => _compacted?.Backend ?? _backend; + set => _backend = value; + } + public int Depth { get; set; } = 0; - public List Children { get; set; } = new List(); + public List Children => _compacted?.Children ?? _children; + public string Name => _backend == null ? string.Empty : Path.GetFileName(_backend.Path) + (_compacted == null ? string.Empty : " / " + _compacted.Name); + public bool IsFolder => _backend?.Type == Models.ObjectType.Tree; - public string Name + public bool IsExpanded { - get => Backend == null ? string.Empty : Path.GetFileName(Backend.Path); + get => _isExpanded; + set + { + _hasExpansionState = true; + SetProperty(ref _isExpanded, value); + if (_compacted != null) + _compacted.IsExpanded = value; + } } - public bool IsFolder + // Keep the original hierarchy: switching compaction off restores its nodes, + // cached children and expansion state without changing any Git object paths. + public async Task PrepareAsync(Func>> loadChildren, bool compactFolders) { - get => Backend?.Type == Models.ObjectType.Tree; + RevisionFileTreeNode compacted = null; + if (IsFolder && compactFolders) + { + await LoadOwnChildrenAsync(loadChildren); + if (_children.Count == 1 && _children[0].IsFolder) + { + var child = _children[0]; + await child.PrepareAsync(loadChildren, true); + compacted = child; + if (child._hasExpansionState && !child.IsExpanded) + IsExpanded = false; + child.IsExpanded = IsExpanded; + } + } + + _compacted = compacted; + OnPropertyChanged(nameof(Name)); + OnPropertyChanged(nameof(Backend)); } - public bool IsExpanded + public async Task> LoadChildrenAsync(Func>> loadChildren) { - get => _isExpanded; - set => SetProperty(ref _isExpanded, value); + if (_compacted != null) + return await _compacted.LoadChildrenAsync(loadChildren); + return await LoadOwnChildrenAsync(loadChildren); + } + + private async Task> LoadOwnChildrenAsync(Func>> loadChildren) + { + if (!IsFolder) + return _children; + if (_children.Count == 0) + { + _loading ??= loadChildren(_backend.Path + "/"); + var objects = await _loading; + if (_children.Count == 0 && objects != null) + { + foreach (var obj in objects) + _children.Add(new RevisionFileTreeNode { Backend = obj }); + } + } + return _children; } + private Models.Object _backend; + private readonly List _children = []; + private RevisionFileTreeNode _compacted; + private Task> _loading; + private bool _hasExpansionState; private bool _isExpanded = false; } } diff --git a/src/Views/ChangeViewModeSwitcher.axaml b/src/Views/ChangeViewModeSwitcher.axaml index b72fcbcb8f..16ef5db7f6 100644 --- a/src/Views/ChangeViewModeSwitcher.axaml +++ b/src/Views/ChangeViewModeSwitcher.axaml @@ -3,26 +3,36 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:m="using:SourceGit.Models" + xmlns:vm="using:SourceGit.ViewModels" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="SourceGit.Views.ChangeViewModeSwitcher" x:Name="ThisControl"> -