diff --git a/docs/compact-folders.md b/docs/compact-folders.md new file mode 100644 index 000000000..df9598b72 --- /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/docs/selected-commit-changes.md b/docs/selected-commit-changes.md new file mode 100644 index 000000000..9a2a44ad5 --- /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 fb008aeee..1680dcf58 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 000000000..b6e171a9d --- /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 000000000..143a419ae --- /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 af64b39a4..f525abaec 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 3ba62d7aa..b7f4586b4 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 8b2bc2010..bf44a9ffe 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 d3ffb203a..a1a663abf 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 @@ -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 96ea1ec58..23a0b9170 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 05b98ea1e..43bea5e2c 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 0abcb3b0c..5f149a8f7 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 5e7e5e57f..e855a7a61 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 630746c5d..187b08749 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 023fc65df..876ce290c 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 219cddf68..6e5a2bf96 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 5084f0180..f5a68f2d2 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 5aef219db..f599c3572 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 3473ff1ca..4fb5e05ee 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 5f44193e3..a7febd1f0 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 90eb61af9..e4a9eddd4 100644 --- a/src/Resources/Locales/zh_CN.axaml +++ b/src/Resources/Locales/zh_CN.axaml @@ -700,7 +700,7 @@ 通用配置 启动时检测软件更新 日期时间格式 - 在变更列表树中启用紧凑文件夹模式 + 合并仅含单个子文件夹的路径 显示语言 最大历史提交数 默认显示【本地更改】页 @@ -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 ef2e3d7ca..672b6be78 100644 --- a/src/Resources/Locales/zh_TW.axaml +++ b/src/Resources/Locales/zh_TW.axaml @@ -700,7 +700,7 @@ 一般設定 啟動時檢查軟體更新 日期時間格式 - 在樹狀變更目錄中啟用密集資料夾模式 + 合併僅含單個子資料夾的路徑 顯示語言 最大歷史提交數 預設顯示 [本機變更] 頁面 @@ -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/ChangeTreeNode.cs b/src/ViewModels/ChangeTreeNode.cs index c35f4fc1f..819c5359b 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/CommitChanges.cs b/src/ViewModels/CommitChanges.cs new file mode 100644 index 000000000..ef6e38bd7 --- /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 636aad665..a8a0df396 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 ddbdffbb6..045b3ed33 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 128b53ff9..159577279 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 1ea5951a8..88505a532 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/ViewModels/RevisionFileTreeNode.cs b/src/ViewModels/RevisionFileTreeNode.cs index 2f94d40d9..93efd5adf 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 b72fcbcb8..16ef5db7f 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"> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/SelectedCommitChanges.axaml.cs b/src/Views/SelectedCommitChanges.axaml.cs new file mode 100644 index 000000000..efbeead1e --- /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/ChangeViewModeMenu/ChangeViewModeMenu.csproj b/tests/ChangeViewModeMenu/ChangeViewModeMenu.csproj new file mode 100644 index 000000000..144c22aaf --- /dev/null +++ b/tests/ChangeViewModeMenu/ChangeViewModeMenu.csproj @@ -0,0 +1,11 @@ + + + Exe + net10.0 + enable + + + + + + diff --git a/tests/ChangeViewModeMenu/Program.cs b/tests/ChangeViewModeMenu/Program.cs new file mode 100644 index 000000000..72ff8e5c7 --- /dev/null +++ b/tests/ChangeViewModeMenu/Program.cs @@ -0,0 +1,126 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Input; +using Avalonia.Threading; +using Avalonia.VisualTree; +using SourceGit.Views; +using Preferences = SourceGit.ViewModels.Preferences; +using SourceGit.Models; + +SourceGit.Native.OS.SetupBasicDirectories(); +AppBuilder.Configure().WithInterFont().UseSkia() + .UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false }).SetupWithoutStarting(); +var window = new Window { Width = 600, Height = 350 }; +window.Show(); +MenuChecks.Run(window); +window.Close(); + +static class MenuChecks +{ + static void Pump() { using var cancellation = new CancellationTokenSource(650); Dispatcher.UIThread.MainLoop(cancellation.Token); } + static void Check(bool ok, string message) { if (!ok) throw new Exception(message); Console.WriteLine("PASS menu: " + message); } + static void Click(Control control) + { + var top = TopLevel.GetTopLevel(control); + var point = control.TranslatePoint(new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), top).Value; + top.MouseMove(point); + top.MouseDown(point, MouseButton.Left); + top.MouseUp(point, MouseButton.Left); + Pump(); + } + public static void Run(Window window) + { + var prior = Preferences.Instance.EnableCompactFoldersInChangesTree; + var view = new ChangeViewModeSwitcher { ViewMode = ChangeViewMode.Grid, HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Left, VerticalAlignment = Avalonia.Layout.VerticalAlignment.Top }; + window.Content = view; + Pump(); + var button = view.GetVisualDescendants().OfType