diff --git a/README.md b/README.md index 2ac5ba0723..14ecf7fa6a 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,55 @@ You can find custom themes from [sourcegit-theme](https://github.com/sourcegit-scm/sourcegit-theme.git). And welcome to share your own themes. +### Hierarchical submodule history + +Inspect submodule updates in the parent repository's history without checking out another commit. This example is an application with separate core and UI repositories. Both use a common library, which depends on a foundation library. + +The names on the left are **checkout paths**; the names on the right identify the **Git repositories**. Every entry marked `submodule` is a separate gitlink. `modules/` and the `deps/` prefixes are ordinary directories. + +```text +demo-app/ repository: demo-app (top level) +└── modules/ + ├── core/ submodule: demo-core [depth 1] + │ └── deps/common/ submodule: demo-common [depth 2] + │ └── deps/foundation/ submodule: demo-foundation [depth 3] + └── ui/ submodule: demo-ui [depth 1] + └── deps/common/ submodule: demo-common [depth 2] + └── deps/foundation/ submodule: demo-foundation [depth 3] +``` + +`modules/core` and `modules/ui` refer to different repositories. Their `deps/common` submodules use the same source repository but have separate checkouts and independently recorded pointers. The same applies to the two foundation submodules. + +The example includes several kinds of history: + +| Repository | Example commits and relationships | +| --- | --- | +| `demo-app` | Application configuration and documentation; core-only and UI-only pointer updates; a joint update for release; `v0.1.0` and `v0.2.0` tags. | +| `demo-core` | Response caching, a cache expiry fix, a common-library update, and a merge of `feature/batch-requests`. | +| `demo-ui` | Empty-state support, a loading-indicator fix, a common-library update, and a merge of `feature/dark-theme`. | +| `demo-common` | Retry policy changes and a foundation pointer update. | +| `demo-foundation` | Initial timeout defaults followed by connection and read timeout fixes. | + +**Screenshot settings.** These captures use the default **changed range** history mode and the **repository:path:branch** label format, selected in **Preferences → Submodule history**. Paths are relative to `demo-app`, so `[demo-common:modules/core/deps/common:main]` identifies both the repository and its location. The default label format remains `[path:branch]`. The area limit is set to **4** for this walkthrough (default: **3**). + +**1. Branch tree in one tab.** Select `build: update core and ui for release` in `demo-app`. By default, just the **first actual commit** of each direct submodule appears. Click **▸** on core's first commit to show its remaining history; click **▾** to hide it again. The first commit keeps its message, SHA, author, and date in either state—there is no separate summary row. In this screenshot core is expanded and UI retains only its first commit. Clicking that UI commit shows its commit details below. + +![demo-core history expanded and demo-ui retaining its first actual commit with a disclosure arrow](./screenshots/submodule_branch_tree.png) + +Click the parent commit again to **hide all of its submodule rows**, including the retained first commits and nested histories. Another click reveals only the direct first commits again. **Collapse all** instead reduces each visible top-level area to its first commit. + +**2. Explore nested modules by clicking.** Choose **Recursive exploration** and use the arrows to expand core and UI. Select `build: adopt common retry policy` inside `modules/core` to reveal common's first commit, then expand common with its arrow. Select `build: adopt foundation timeout fixes` to reveal foundation's first commit and expand it the same way. The breadcrumb and labels identify `demo-app → modules/core → deps/common → deps/foundation`. Four areas are expanded in this screenshot. UI's nested dependencies are not explored yet. First-commit previews do not count toward the expansion limit; the default scope is direct submodules only. + +![demo-foundation selected inside demo-core, with disclosure arrows on the first commits of each expanded area](./screenshots/submodule_nested_tree.png) + +In **Preferences → Submodule history**, enable **Automatically expand histories when selecting a commit** to open the detailed histories on selection. Enable **Automatically collapse other submodule histories** to have opening UI collapse core to its first commit. This replaces areas under the same parent repository while keeping the ancestors needed for nested navigation. Both options are off by default; these screenshots use those defaults. + +**3. Compare a submodule update from the top repository.** Return to the release update commit in `demo-app`, choose **Compare gitlink change → modules/core**, and inspect the commits added/removed between core's previous and new pointers, followed by the file patch. The patch includes the cache expiry fix, batch settings, and the nested common-library gitlink update. For a merge with several previous pointers, choose the baseline from the dropdown. The recorded gitlink and local HEAD status remain visible. This compares **two revisions of `demo-core`**, not core against UI. + +![demo-core gitlink comparison from demo-app, showing previous and new pointers, added commits, and file changes](./screenshots/submodule_context_diff.png) + +These screenshots capture the actual history panel using local example repositories. See the [feature guide](./docs/hierarchical-submodule-history.md) for settings and limitations. + ## Highlights * Supports Windows/macOS/Linux diff --git a/docs/hierarchical-submodule-history.md b/docs/hierarchical-submodule-history.md new file mode 100644 index 0000000000..e80fa28c4a --- /dev/null +++ b/docs/hierarchical-submodule-history.md @@ -0,0 +1,39 @@ +# Hierarchical submodule history + +Select a commit in the parent repository's history to reveal the first actual commit of each direct submodule whose gitlink changed. Each first commit has a **▸ / ▾** disclosure control on its own log row. Its message, SHA, author and date remain visible when the area is collapsed. No extra summary row is inserted for a readable submodule. Display-only edges connect the parent commit to these submodule commits; expanded areas also connect previous pointers back to the earlier parent commit that recorded them (when visible). Real Git parents and repository history remain unchanged. + +In **Preferences → Submodule history**: + +- **Changed range** (default): commits reachable from the new gitlink but not the old gitlink(s), plus explicit previous-pointer boundaries. Rewinds and divergent updates retain both endpoints. +- **Full history**: the ancestry reachable from the new and previous gitlinks, including all merge parents. +- **Nesting scope**: **Direct submodules only** (default) keeps navigation at the first level. **Recursive exploration** lets you select a commit inside an expanded area to reveal the next level of changed gitlinks. Each click explores one level; descendants are not loaded automatically. Nested labels remain relative to the top repository. +- **Maximum expanded submodule areas**: defaults to 3; configurable from 1 to 20. This limits areas across all depths and selected parent commits, not the number of internal merge lanes. The history commit limit also bounds each query; truncation is reported. + +- **Automatically expand histories when selecting a commit**: off by default, so selection reveals only first-commit previews. Enable it to also load and expand the changed areas up to the area limit. +- **Automatically collapse other submodule histories**: off by default, so opening a new area keeps existing ones. Enable it to replace other expanded areas under the same immediate parent repository, including their descendants. Their first commits remain visible; ancestors of the newly opened area stay expanded. Replacement works even at the area limit. A failed expansion leaves the existing area open. If both automatic options are enabled, selection opens the first available changed area. + +These two interaction settings apply to subsequent selections and expansions. + +| Action | Result | +| --- | --- | +| Select a parent commit | Show one real log per direct changed submodule by default. | +| Click **▸** on a submodule's first commit | Expand that area's remaining history. | +| Click **▾** on a submodule's first commit | Keep the first log and hide the remaining commits and all nested areas. | +| Click the parent commit again | Hide all rows belonging to its submodules, including first logs that were already collapsed. Other parent commits' areas are unaffected. | +| Click the hidden area's parent commit again | Reveal its direct first logs again, or auto-expand them if enabled. | +| **Collapse all** | Reduce visible top-level areas to their first logs. | +| Click a retained commit's body | Show that commit's details without reopening the area. Use **Compare gitlink change** separately to compare pointers. | + +**Space / Enter** on a selected first commit also toggles its area. In recursive mode, selecting a commit inside an expanded area reveals first logs for its changed nested submodules; their arrows expand the next level. First-commit previews do not consume the expanded-area limit. Collapsed first logs display **Collapsed**; blocked expansion shows **Expansion limit reached · N / N**. If the repository or commit objects cannot be read, an explicit error placeholder appears instead of fabricating a commit. Selecting an unrelated commit keeps previously explored areas; refreshing history or changing scope/mode rebuilds the view. + +Labels use paths relative to the top repository. **Preferences → Submodule history → Submodule label** offers `[relative path:branch]` (default), `[repository name:relative path:branch]`, and `[relative path]`. Repository names come from the local origin URL's final component (without credentials or `.git`); without origin, the submodule directory name is used. Branch labels describe local refs currently pointing exactly at the selected gitlink: the current branch is preferred, then local branches, then remote branches. When no ref matches, the label is `detached@`. A gitlink does not record historical branch names, so they are not guessed from the parent repository's branch. Changing label format updates already expanded rows immediately. + +Local initialization and the referenced Git objects are required; the viewer never initializes, fetches, checks out, or modifies a submodule. Deleted, moved, or uninitialized checkouts and missing objects are reported. Historical branch names cannot be inferred from a gitlink itself. The existing first-parent-only display option is temporarily superseded while areas are expanded so their merge connections remain visible. + +**Compare gitlink change** opens a read-only comparison in the same detail panel, for changed submodules or the selected child area. Choose a previous pointer when the owner has multiple parents. The view lists commits added and removed from reachability (bounded by the history limit), file statistics and a unified patch between the two trees. Additions and deletions use an empty tree for the absent endpoint when local objects remain available. Missing checkouts or objects report an error; no fetch or checkout runs. + +Child commit details and gitlink comparisons display the recorded gitlink and current local HEAD. Status distinguishes matching, different, removed, and unavailable pointers. This compares the area's recorded endpoint, not an arbitrary historical row or the working tree's uncommitted contents. Use **Refresh pointer status** after an external checkout to refresh this read-only snapshot. + +For a child commit or gitlink comparison, a clickable path above the graph shows the top repository and each nested submodule. **Go to owner commit** selects the immediate parent commit that opened the area; a path button selects its corresponding ancestor. These navigation actions preserve expanded areas and never auto-expand siblings. A comparison for a collapsed area still shows its path; unavailable graph targets are disabled. + +The toolbar provides **Collapse all**, an expanded-area counter, and menus for history and comparison. Individual controls are on each area's first real commit. Double-clicking a parent-repository commit retains SourceGit's checkout behavior; use single clicks for history visibility. Child-commit and unavailable-placeholder double-clicks never check out a commit. Expanding, collapsing, comparing, and navigating submodule history never move HEAD. diff --git a/screenshots/submodule_branch_tree.png b/screenshots/submodule_branch_tree.png new file mode 100644 index 0000000000..4882e77909 Binary files /dev/null and b/screenshots/submodule_branch_tree.png differ diff --git a/screenshots/submodule_context_diff.png b/screenshots/submodule_context_diff.png new file mode 100644 index 0000000000..4c52cc7ee6 Binary files /dev/null and b/screenshots/submodule_context_diff.png differ diff --git a/screenshots/submodule_nested_tree.png b/screenshots/submodule_nested_tree.png new file mode 100644 index 0000000000..7a17c22e68 Binary files /dev/null and b/screenshots/submodule_nested_tree.png differ diff --git a/src/Commands/QuerySubmoduleHistory.cs b/src/Commands/QuerySubmoduleHistory.cs new file mode 100644 index 0000000000..a4fa968941 --- /dev/null +++ b/src/Commands/QuerySubmoduleHistory.cs @@ -0,0 +1,329 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + // All commands are read-only and use argument lists (paths may contain spaces or quotes). + public class QuerySubmoduleHistory(string repository, CancellationToken cancellationToken = default) + { + public async Task> GetChangesAsync(Models.Commit commit) + { + var current = await ReadLinksAsync(commit.SHA).ConfigureAwait(false); + var parents = new List>(); + foreach (var parent in commit.Parents) + parents.Add(await ReadLinksAsync(parent).ConfigureAwait(false)); + + var paths = current.Keys.Concat(parents.SelectMany(x => x.Keys)).Distinct().Order(StringComparer.Ordinal); + var result = new List(); + foreach (var path in paths) + { + current.TryGetValue(path, out var next); + if (parents.Count > 0 && parents.All(p => p.GetValueOrDefault(path) == next)) + continue; + + var section = new Models.SubmoduleHistory + { + OwnerSHA = commit.SHA, + ParentHistory = commit.SubmoduleHistory, + LocalPath = path, + Path = commit.SubmoduleHistory == null ? path : commit.SubmoduleHistory.Path + "/" + path, + NewSHA = next, + }; + for (var i = 0; i < parents.Count; i++) + { + if (!parents[i].TryGetValue(path, out var old) || section.Previous.ContainsKey(old)) + continue; + var previousChange = await GitAsync(repository, ["log", "-1", "--format=%H", commit.Parents[i], "--", path]).ConfigureAwait(false); + section.Previous[old] = previousChange.Trim(); + } + result.Add(section); + } + return result; + } + + public async Task ReadPreviewAsync(Models.SubmoduleHistory section) + { + section.Error = string.Empty; + try + { + section.RepositoryPath = ResolvePath(section); + var tips = section.Previous.Keys.ToList(); + if (section.NewSHA != null) + tips.Insert(0, section.NewSHA); + await ReadIdentityAsync(section, tips[0]).ConfigureAwait(false); + var args = new List { "log", "--topo-order", "--max-count=1", "--no-show-signature", "--format=%H%x00%P%x00%aN±%aE%x00%at%x00%cN±%cE%x00%ct%x00%s" }; + args.AddRange(tips.Distinct()); + args.Add("--"); + section.PreviewCommit = ParseLog(await GitAsync(section.RepositoryPath, args).ConfigureAwait(false)).FirstOrDefault(); + } + catch (OperationCanceledException) { throw; } + catch (Exception e) + { + section.Error = e is InvalidOperationException && e.Message == "SubmoduleHistory.Unavailable" + ? "SubmoduleHistory.Unavailable" : "SubmoduleHistory.MissingObjects"; + } + } + + public async Task LoadAsync(Models.SubmoduleHistory section, Models.SubmoduleHistoryMode mode, int limit) + { + section.Commits = []; + section.Error = string.Empty; + section.IsTruncated = false; + try + { + var path = ResolvePath(section); + section.RepositoryPath = path; + var tips = section.Previous.Keys.ToList(); + if (section.NewSHA != null) + tips.Insert(0, section.NewSHA); + foreach (var tip in tips.Distinct()) + await GitAsync(path, ["cat-file", "-e", tip + "^{commit}"]).ConfigureAwait(false); + + await ReadIdentityAsync(section, tips[0]).ConfigureAwait(false); + limit = Math.Max(1, limit); + var args = new List { "log", "--topo-order", "--no-show-signature", "--format=%H%x00%P%x00%aN±%aE%x00%at%x00%cN±%cE%x00%ct%x00%s", $"--max-count={limit + 1}" }; + if (mode == Models.SubmoduleHistoryMode.FullHistory) + { + args.AddRange(tips.Distinct()); + } + else if (section.NewSHA != null) + { + args.Add(section.NewSHA); + if (section.Previous.Count > 0) + { + args.Add("--not"); + args.AddRange(section.Previous.Keys); + } + } + else + { + args.Add("--no-walk"); + args.AddRange(tips.Distinct()); + } + args.Add("--"); + var commits = ParseLog(await GitAsync(path, args).ConfigureAwait(false)); + section.IsTruncated = commits.Count > limit; + if (section.IsTruncated) + commits.RemoveAt(commits.Count - 1); + + if (mode == Models.SubmoduleHistoryMode.ChangedRange) + { + // Include the old pointer as an explicit boundary, also for rewinds. + foreach (var old in section.Previous.Keys) + { + if (commits.Any(c => c.SHA == old)) + continue; + var boundary = await GitAsync(path, ["log", "-1", "--no-show-signature", "--format=%H%x00%P%x00%aN±%aE%x00%at%x00%cN±%cE%x00%ct%x00%s", old, "--"]).ConfigureAwait(false); + commits.AddRange(ParseLog(boundary)); + } + if (section.NewSHA != null && commits.All(c => c.SHA != section.NewSHA)) + { + var tip = await GitAsync(path, ["log", "-1", "--no-show-signature", "--format=%H%x00%P%x00%aN±%aE%x00%at%x00%cN±%cE%x00%ct%x00%s", section.NewSHA, "--"]).ConfigureAwait(false); + commits.AddRange(ParseLog(tip)); + } + } + // Boundary tips can be ancestors of one another (rewinds and merge commits). + // Keep children before their parents even when boundaries were appended later. + var positions = commits.Select((c, i) => (c.SHA, i)).ToDictionary(x => x.SHA, x => x.i); + var childCounts = commits.ToDictionary(c => c.SHA, _ => 0); + foreach (var commit in commits) + { + foreach (var parent in commit.Parents) + { + if (childCounts.TryGetValue(parent, out var count)) + childCounts[parent] = count + 1; + } + } + var ready = new PriorityQueue(); + foreach (var commit in commits) + { + if (childCounts[commit.SHA] == 0) + ready.Enqueue(commit, positions[commit.SHA]); + } + var ordered = new List(); + while (ready.TryDequeue(out var commit, out _)) + { + ordered.Add(commit); + foreach (var parent in commit.Parents) + { + if (childCounts.TryGetValue(parent, out var count)) + { + childCounts[parent] = count - 1; + if (count == 1) + ready.Enqueue(commits[positions[parent]], positions[parent]); + } + } + } + section.Commits = ordered; + section.PreviewCommit = ordered.FirstOrDefault(); + } + catch (OperationCanceledException) { throw; } + catch (Exception e) + { + section.Error = e is InvalidOperationException && e.Message == "SubmoduleHistory.Unavailable" + ? "SubmoduleHistory.Unavailable" : "SubmoduleHistory.MissingObjects"; + } + } + + private string ResolvePath(Models.SubmoduleHistory section) + { + var root = System.IO.Path.GetFullPath(repository); + var path = System.IO.Path.GetFullPath(System.IO.Path.Combine(root, section.LocalPath ?? section.Path)); + if (!path.StartsWith(root + System.IO.Path.DirectorySeparatorChar, StringComparison.Ordinal) || + !(File.Exists(System.IO.Path.Combine(path, ".git")) || Directory.Exists(System.IO.Path.Combine(path, ".git")))) + throw new InvalidOperationException("SubmoduleHistory.Unavailable"); + return path; + } + + public async Task ReadPointerStatusAsync(Models.SubmoduleHistory section) + { + try + { + var path = ResolvePath(section); + var head = (await GitAsync(path, ["rev-parse", "--verify", "HEAD^{commit}"]).ConfigureAwait(false)).Trim(); + return new Models.SubmodulePointerStatus + { + LocalHEAD = head, + State = section.NewSHA == null ? Models.SubmodulePointerState.Removed : + head == section.NewSHA ? Models.SubmodulePointerState.Matches : Models.SubmodulePointerState.Differs, + }; + } + catch (OperationCanceledException) { throw; } + catch (Exception) + { + return new Models.SubmodulePointerStatus { State = Models.SubmodulePointerState.Unavailable }; + } + } + + public async Task CompareAsync(Models.SubmoduleHistory section, string previousSHA, int limit) + { + if (previousSHA == null ? section.Previous.Count > 0 : !section.Previous.ContainsKey(previousSHA)) + throw new ArgumentException("Unknown previous gitlink", nameof(previousSHA)); + var path = ResolvePath(section); + var empty = (await GitAsync(path, ["hash-object", "-t", "tree", "--stdin"]).ConfigureAwait(false)).Trim(); + var from = previousSHA ?? empty; + var to = section.NewSHA ?? empty; + var added = await ReadRange(section.NewSHA, previousSHA).ConfigureAwait(false); + var removed = await ReadRange(previousSHA, section.NewSHA).ConfigureAwait(false); + var patch = await GitAsync(path, ["diff", "--no-ext-diff", "--no-textconv", "--no-color", "--ignore-submodules=none", "--submodule=short", "--stat", "--patch", from, to, "--"]).ConfigureAwait(false); + return new Models.SubmoduleComparison { Added = added, Removed = removed, Patch = patch }; + + async Task ReadRange(string tip, string excluded) + { + if (tip == null) + return string.Empty; + var args = new List { "log", "--no-show-signature", "--format=%h %s", "--topo-order", $"--max-count={Math.Max(1, limit)}", tip }; + if (excluded != null) + args.AddRange(["--not", excluded]); + args.Add("--"); + return await GitAsync(path, args).ConfigureAwait(false); + } + } + + public Task ReadCommitAsync(string path, string sha) => + GitAsync(path, ["show", "--no-ext-diff", "--no-textconv", "--no-show-signature", "--format=fuller", "--stat", sha, "--"]); + + private async Task ReadIdentityAsync(Models.SubmoduleHistory section, string tip) + { + var origin = await TryGitAsync(section.RepositoryPath, ["config", "--get", "remote.origin.url"]).ConfigureAwait(false); + section.RepositoryName = Models.SubmoduleHistory.GetRepositoryName(origin.Trim(), System.IO.Path.GetFileName(section.Path)); + var current = (await TryGitAsync(section.RepositoryPath, ["symbolic-ref", "--quiet", "HEAD"]).ConfigureAwait(false)).Trim(); + var refs = (await GitAsync(section.RepositoryPath, + ["for-each-ref", "--format=%(refname)", "--points-at", tip, "refs/heads/", "refs/remotes/"]).ConfigureAwait(false)) + .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries) + .Where(r => !r.EndsWith("/HEAD", StringComparison.Ordinal)).Order(StringComparer.Ordinal).ToList(); + var branch = refs.Contains(current) ? current : refs.FirstOrDefault(); + section.BranchName = branch == null ? $"detached@{tip[..Math.Min(8, tip.Length)]}" : + branch.StartsWith("refs/heads/", StringComparison.Ordinal) ? branch[11..] : branch[13..]; + } + + private async Task TryGitAsync(string path, IEnumerable args) + { + try + { + return await GitAsync(path, args).ConfigureAwait(false); + } + catch (InvalidOperationException) { return string.Empty; } + } + + private async Task> ReadLinksAsync(string revision) + { + var output = await GitAsync(repository, ["ls-tree", "-r", "-z", revision]).ConfigureAwait(false); + var links = new Dictionary(StringComparer.Ordinal); + foreach (var entry in output.Split('\0', StringSplitOptions.RemoveEmptyEntries)) + { + var tab = entry.IndexOf('\t'); + if (tab > 0 && entry.StartsWith("160000 commit ", StringComparison.Ordinal)) + links[entry[(tab + 1)..]] = entry[14..tab]; + } + return links; + } + + private static List ParseLog(string text) + { + var result = new List(); + foreach (var line in text.Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { + var fields = line.Split('\0'); + if (fields.Length != 7) + continue; + var commit = new Models.Commit + { + SHA = fields[0], + Author = Models.User.FindOrAdd(fields[2]), + AuthorTime = ulong.Parse(fields[3]), + Committer = Models.User.FindOrAdd(fields[4]), + CommitterTime = ulong.Parse(fields[5]), + Subject = fields[6].TrimEnd('\r'), + }; + commit.ParseParents(fields[1]); + result.Add(commit); + } + return result; + } + + private async Task GitAsync(string path, IEnumerable args) + { + cancellationToken.ThrowIfCancellationRequested(); + using var process = new Process(); + process.StartInfo = new ProcessStartInfo + { + FileName = string.IsNullOrEmpty(Native.OS.GitExecutable) ? "git" : Native.OS.GitExecutable, + WorkingDirectory = path, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + // A history viewer should report unavailable objects, not trigger a promisor fetch. + process.StartInfo.Environment["GIT_NO_LAZY_FETCH"] = "1"; + process.StartInfo.ArgumentList.Add("--no-pager"); + foreach (var arg in args) + process.StartInfo.ArgumentList.Add(arg); + process.Start(); + process.StandardInput.Close(); + using var registration = cancellationToken.Register(() => + { + try + { + if (!process.HasExited) + process.Kill(true); + } + catch (InvalidOperationException) { } + }); + var output = process.StandardOutput.ReadToEndAsync(cancellationToken); + var error = process.StandardError.ReadToEndAsync(cancellationToken); + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + await Task.WhenAll(output, error).ConfigureAwait(false); + if (process.ExitCode != 0) + throw new InvalidOperationException(await error.ConfigureAwait(false)); + return await output.ConfigureAwait(false); + } + } +} diff --git a/src/Models/Commit.cs b/src/Models/Commit.cs index de299ed6b7..fceb989406 100644 --- a/src/Models/Commit.cs +++ b/src/Models/Commit.cs @@ -24,6 +24,13 @@ public class Commit : ObservableObject public List Parents { get; set; } = new(); public List Decorators { get; set; } = new(); + public SubmoduleHistory SubmoduleHistory { get; set; } + public bool IsSubmoduleHeader { get; set; } + public bool IsSubmodulePlaceholder { get; set; } + public string GraphKey => IsSubmodulePlaceholder ? SubmoduleHistory.Key + ":unavailable" : SubmoduleHistory?.GraphKey(SHA) ?? SHA; + public List GraphParents { get; set; } + public List DisplayParents => GraphParents ?? Parents; + public bool IsMerged { get; set; } = false; public int Color { get; set; } = 0; public double LeftMargin { get; set; } = 0; diff --git a/src/Models/CommitGraph.cs b/src/Models/CommitGraph.cs index 916a79b1bb..2608297647 100644 --- a/src/Models/CommitGraph.cs +++ b/src/Models/CommitGraph.cs @@ -92,13 +92,22 @@ public static CommitGraph Generate(List commits, bool firstParentOnlyEna // Update current y offset offsetY += unitHeight; + // Unavailable repositories have no Git commit node to draw. + if (commit.IsSubmodulePlaceholder) + { + foreach (var lane in unsolved) + lane.Pass(lane.LastX, offsetY, halfHeight); + commit.LeftMargin = (unsolved.Count > 0 ? unsolved[^1].LastX : 10) + unitWidth; + continue; + } + // Find first curves that links to this commit and marks others that links to this commit ended. var offsetX = 4 - halfWidth; var maxOffsetOld = unsolved.Count > 0 ? unsolved[^1].LastX : offsetX + unitWidth; var isHighlighted = defHighlighting; foreach (var l in unsolved) { - if (l.Next.Equals(commit.SHA, StringComparison.Ordinal)) + if (l.Next.Equals(commit.GraphKey, StringComparison.Ordinal)) { if (major == null) { @@ -106,9 +115,9 @@ public static CommitGraph Generate(List commits, bool firstParentOnlyEna major = l; isHighlighted = major.IsHighlighted; - if (commit.Parents.Count > 0) + if (commit.DisplayParents.Count > 0) { - major.Next = commit.Parents[0]; + major.Next = commit.DisplayParents[0]; major.Goto(offsetX, offsetY, halfHeight); } else @@ -151,12 +160,12 @@ public static CommitGraph Generate(List commits, bool firstParentOnlyEna break; case CommitGraphHighlighting.SelectedCommitsOnly: case CommitGraphHighlighting.SelectedCommitsOnlyFirstParent: - if (highlightExtraCommits.Remove(commit.SHA)) + if (highlightExtraCommits.Remove(commit.GraphKey)) { isHighlighted = true; // Highlight first parent, other parents are dealt with later - if (commit.Parents.Count > 0) - highlightExtraCommits.Add(commit.Parents[0]); + if (commit.DisplayParents.Count > 0) + highlightExtraCommits.Add(commit.DisplayParents[0]); } break; default: // CommitGraphHighlighting.CurrentBranchAndSelectedCommits @@ -164,12 +173,12 @@ public static CommitGraph Generate(List commits, bool firstParentOnlyEna { isHighlighted = true; } - else if (highlightExtraCommits.Remove(commit.SHA)) + else if (highlightExtraCommits.Remove(commit.GraphKey)) { isHighlighted = true; // Highlight first parent, other parents are dealt with later - if (commit.Parents.Count > 0) - highlightExtraCommits.Add(commit.Parents[0]); + if (commit.DisplayParents.Count > 0) + highlightExtraCommits.Add(commit.DisplayParents[0]); } break; } @@ -182,14 +191,14 @@ public static CommitGraph Generate(List commits, bool firstParentOnlyEna { offsetX += unitWidth; - if (commit.Parents.Count > 0) + if (commit.DisplayParents.Count > 0) { - major = new PathHelper(commit.Parents[0], isHighlighted, colorPicker.Next(), new Point(offsetX, offsetY)); + major = new PathHelper(commit.DisplayParents[0], isHighlighted, colorPicker.Next(), new Point(offsetX, offsetY)); unsolved.Add(major); temp.Paths.Add(major.Path); } } - else if (isHighlighted && !major.IsHighlighted && commit.Parents.Count > 0) + else if (isHighlighted && !major.IsHighlighted && commit.DisplayParents.Count > 0) { major.Highlight(); temp.Paths.Add(major.Path); @@ -201,7 +210,7 @@ public static CommitGraph Generate(List commits, bool firstParentOnlyEna var anchor = new Dot() { Center = position, Color = dotColor, IsHighlighted = isHighlighted }; if (commit.IsCurrentHead) anchor.Type = DotType.Head; - else if (commit.Parents.Count > 1) + else if (commit.DisplayParents.Count > 1) anchor.Type = DotType.Merge; else anchor.Type = DotType.Default; @@ -213,9 +222,9 @@ public static CommitGraph Generate(List commits, bool firstParentOnlyEna if (highlighting == CommitGraphHighlighting.SelectedCommitsOnlyFirstParent) isHighlighted = false; - for (int j = 1; j < commit.Parents.Count; j++) + for (int j = 1; j < commit.DisplayParents.Count; j++) { - var parentHash = commit.Parents[j]; + var parentHash = commit.DisplayParents[j]; var parent = unsolved.Find(x => x.Next.Equals(parentHash, StringComparison.Ordinal)); if (parent != null) { diff --git a/src/Models/SubmoduleComparison.cs b/src/Models/SubmoduleComparison.cs new file mode 100644 index 0000000000..d019693472 --- /dev/null +++ b/src/Models/SubmoduleComparison.cs @@ -0,0 +1,9 @@ +namespace SourceGit.Models +{ + public class SubmoduleComparison + { + public string Added { get; init; } + public string Removed { get; init; } + public string Patch { get; init; } + } +} diff --git a/src/Models/SubmoduleHistory.cs b/src/Models/SubmoduleHistory.cs new file mode 100644 index 0000000000..fa9fee5c45 --- /dev/null +++ b/src/Models/SubmoduleHistory.cs @@ -0,0 +1,164 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace SourceGit.Models +{ + public enum SubmoduleHistoryScope + { + DirectOnly, + Recursive, + } + + public enum SubmoduleHistoryMode + { + ChangedRange, + FullHistory, + } + + public enum SubmoduleHistoryLabelFormat + { + PathAndBranch, + RepositoryPathAndBranch, + PathOnly, + } + + public class SubmoduleHistory + { + public string OwnerSHA { get; init; } + public string Path { get; init; } + public string LocalPath { get; init; } + public SubmoduleHistory ParentHistory { get; init; } + public string OwnerKey => ParentHistory?.GraphKey(OwnerSHA) ?? OwnerSHA; + public string RepositoryPath { get; set; } + public string NewSHA { get; init; } + public string RepositoryName { get; set; } = string.Empty; + public string BranchName { get; set; } = string.Empty; + public Dictionary Previous { get; } = new(); + public List Commits { get; set; } = []; + public string Error { get; set; } = string.Empty; + public bool IsTruncated { get; set; } + public bool IsExpanded { get; set; } + public bool IsLoading { get; set; } + public string ExpansionState { get; set; } = string.Empty; + public Commit PreviewCommit { get; set; } + public Commit HeaderCommit => Commits.FirstOrDefault() ?? PreviewCommit; + public string HeaderKey => HeaderCommit == null ? Key + ":unavailable" : GraphKey(HeaderCommit.SHA); + public string ToggleGlyph => IsExpanded ? "▾" : "▸"; + public string ToggleDescription => $"{App.Text(IsExpanded ? "SubmoduleHistory.Collapse" : "SubmoduleHistory.Expand")} {Path}"; + public string UnavailableLabel => $"{(string.IsNullOrEmpty(RepositoryName) ? System.IO.Path.GetFileName(Path) : RepositoryName)} · {Path}" + + (string.IsNullOrEmpty(BranchName) ? string.Empty : $" · {BranchName}"); + public string Key => OwnerKey + ":" + Path; + public string GraphKey(string sha) => Key + ":" + sha; + + public string GetLabel(SubmoduleHistoryLabelFormat format) => format switch + { + SubmoduleHistoryLabelFormat.RepositoryPathAndBranch => $"[{RepositoryName}:{Path}:{BranchName}]", + SubmoduleHistoryLabelFormat.PathOnly => $"[{Path}]", + _ => $"[{Path}:{BranchName}]", + }; + + public static string GetRepositoryName(string remote, string fallback) + { + // Never expose URL userinfo, query parameters or fragments in a label. + var path = Uri.TryCreate(remote, UriKind.Absolute, out var uri) + ? uri.AbsolutePath : remote.Split('?', '#')[0]; + path = path.Replace('\\', '/').TrimEnd('/'); + var start = Math.Max(path.LastIndexOf('/'), path.LastIndexOf(':')) + 1; + var name = path[start..]; + if (name.EndsWith(".git", StringComparison.OrdinalIgnoreCase)) + name = name[..^4]; + return string.IsNullOrWhiteSpace(name) ? fallback : name; + } + + // Build display-only edges. Never change the commits returned by Git. + public static List Compose(List roots, IReadOnlyList expanded, SubmoduleHistoryLabelFormat labelFormat = SubmoduleHistoryLabelFormat.PathAndBranch, IReadOnlyList known = null) + { + var result = new List(); + var sectionsByOwner = (known ?? expanded).ToLookup(s => s.OwnerKey); + var expandedKeys = expanded.Select(s => s.Key).ToHashSet(); + Append(roots, null, new()); + + void Append(List commits, SubmoduleHistory context, Dictionary returns, bool showNested = true) + { + var positions = commits.Select((c, i) => (c.SHA, i)).ToDictionary(x => x.SHA, x => x.i); + string Key(string sha) => context?.GraphKey(sha) ?? sha; + foreach (var commit in commits) + { + var row = Copy(commit); + row.SubmoduleHistory = context; + row.IsSubmoduleHeader = context != null && commit == commits[0]; + row.GraphParents = context == null ? new(commit.Parents) : + commit.Parents.Where(positions.ContainsKey).Select(Key).ToList(); + if (context != null) + { + row.Subject = $"{context.GetLabel(labelFormat)} {commit.Subject}"; + row.Decorators = []; + row.IsMerged = true; + } + if (returns.TryGetValue(commit.SHA, out var target)) + row.GraphParents.Add(target); + result.Add(row); + + if (!showNested) + continue; + foreach (var section in sectionsByOwner[row.GraphKey]) + { + if (section.HeaderCommit == null) + { + // Only an unavailable/loading repository needs a non-commit placeholder. + result.Add(new Commit + { + SHA = section.OwnerSHA, + SubmoduleHistory = section, + IsSubmoduleHeader = true, + IsSubmodulePlaceholder = true, + Subject = section.UnavailableLabel, + GraphParents = [], + IsMerged = true, + }); + continue; + } + if (!expandedKeys.Contains(section.Key) || section.Commits.Count == 0) + { + row.GraphParents.Add(section.HeaderKey); + Append([section.HeaderCommit], section, new(), false); + continue; + } + var visible = section.Commits.Select(c => c.SHA).ToHashSet(); + if (visible.Contains(section.NewSHA)) + row.GraphParents.Add(section.GraphKey(section.NewSHA)); + // Rewinds and divergent updates must also expose previous pointers. + foreach (var old in section.Previous.Keys) + { + if (visible.Contains(old) && old != section.NewSHA) + row.GraphParents.Add(section.GraphKey(old)); + } + + var boundaries = new Dictionary(); + foreach (var (old, previousChange) in section.Previous) + { + if (positions.TryGetValue(previousChange, out var position) && position > positions[commit.SHA]) + boundaries[old] = Key(previousChange); + } + Append(section.Commits, section, boundaries); + } + } + } + return result; + } + + private static Commit Copy(Commit c) => new() + { + SHA = c.SHA, + Parents = new(c.Parents), + Subject = c.Subject, + Author = c.Author, + AuthorTime = c.AuthorTime, + Committer = c.Committer, + CommitterTime = c.CommitterTime, + Decorators = new(c.Decorators), + IsMerged = c.IsMerged, + }; + } +} diff --git a/src/Models/SubmodulePointerStatus.cs b/src/Models/SubmodulePointerStatus.cs new file mode 100644 index 0000000000..904511e4df --- /dev/null +++ b/src/Models/SubmodulePointerStatus.cs @@ -0,0 +1,16 @@ +namespace SourceGit.Models +{ + public enum SubmodulePointerState + { + Matches, + Differs, + Removed, + Unavailable, + } + + public class SubmodulePointerStatus + { + public string LocalHEAD { get; init; } + public SubmodulePointerState State { get; init; } + } +} diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index 76ac9e4dcb..4b15bdc7f7 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -1088,4 +1088,48 @@ Remove Unlock YES + Submodule history + Changed range (previous pointer → selected pointer) + Collapse all + Use the arrow on a submodule's first commit to expand or collapse its history. Clicking the parent commit again hides all of its submodule rows. The limit counts expanded areas across all depths, excluding first-commit previews. Gitlink connections are visual only. Missing objects must be fetched or initialized separately. + Full history reachable from the selected and previous pointers + Expansion limit reached. Collapse an area or increase the limit in Preferences. + Loading submodule history… + Automatically expand histories when selecting a commit + Automatically collapse other submodule histories + By default, selecting a commit shows only the first log of each direct submodule. Auto-collapse keeps one expanded area per parent repository and preserves ancestor areas. + Maximum expanded submodule areas (1–20) + Cannot read submodule commits. Check local objects and fetch missing history. + History to expand + Cannot read gitlinks for this commit. Refresh history and try again. + Select a commit that changes a submodule. + History was limited. Increase the history commit limit in Preferences to see more. + Submodule is not initialized or is no longer present in this checkout. + Submodule label (path relative to the top repository) + [relative path:branch] (default) + [relative path] + [repository name:relative path:branch] + Nesting scope + Direct submodules only (default) + Recursive exploration (expand by clicking) + Compare gitlink change + (none) + Previous pointer + Commits added (up to history limit) + Commits removed (up to history limit) + File changes between pointers + Recorded gitlink + Local HEAD + Refresh pointer status + Matches recorded pointer + Differs from recorded pointer + Gitlink removed at this commit + Unavailable + Go to owner commit + Expand + Expanded + Collapsed + Expansion limit reached + Collapse + Collapse this area and its expanded descendants diff --git a/src/Resources/Locales/ko_KR.axaml b/src/Resources/Locales/ko_KR.axaml index e0695553fb..63c4ca8ff8 100644 --- a/src/Resources/Locales/ko_KR.axaml +++ b/src/Resources/Locales/ko_KR.axaml @@ -990,4 +990,48 @@ 제거 잠금 해제 예 + 서브모듈 이력 + 변경 구간 (이전 포인터 → 선택한 포인터) + 모두 접기 + 서브모듈 첫 커밋의 화살표로 상세 이력을 펼치거나 접습니다. 상위 커밋을 다시 클릭하면 해당 서브모듈 행이 모두 숨겨집니다. 개수 제한은 모든 깊이의 펼친 영역에 적용되며 첫 로그 미리보기는 제외됩니다. Gitlink 연결은 화면 표시용입니다. 누락된 객체는 별도로 가져오거나 초기화해야 합니다. + 선택한 포인터와 이전 포인터에서 도달 가능한 전체 이력 + 펼침 한도에 도달했습니다. 영역을 접거나 환경설정에서 한도를 늘리세요. + 서브모듈 이력을 불러오는 중… + 커밋 선택 시 서브모듈 상세 이력 자동 펼치기 + 다른 서브모듈을 펼칠 때 기존 이력 자동 접기 + 기본적으로 커밋을 선택하면 직계 서브모듈마다 첫 로그만 표시합니다. 자동 접기는 같은 상위 저장소의 영역 하나만 펼치고, 조상 영역은 유지합니다. + 동시에 펼칠 서브모듈 영역 수 (1–20) + 서브모듈 커밋을 읽을 수 없습니다. 로컬 객체를 확인하고 누락된 이력을 가져오세요. + 펼칠 이력 + 이 커밋의 gitlink를 읽을 수 없습니다. 이력을 새로 고친 뒤 다시 시도하세요. + 서브모듈을 변경한 커밋을 선택하세요. + 이력 개수 제한이 적용되었습니다. 더 보려면 환경설정에서 이력 커밋 수를 늘리세요. + 이 체크아웃에서 서브모듈이 초기화되지 않았거나 삭제되었습니다. + 서브모듈 표시 형식 (상위 저장소 기준 상대위치) + [상대위치:브랜치명] (기본) + [상대위치] + [저장소명:상대위치:브랜치명] + 중첩 범위 + 직계 서브모듈만 (기본값) + 재귀적 탐색 (클릭하여 단계별 확장) + Gitlink 변경 비교 + (없음) + 이전 포인터 + 추가된 커밋 (이력 개수 제한 적용) + 제외된 커밋 (이력 개수 제한 적용) + 포인터 사이의 파일 변경 + 기록된 gitlink + 로컬 HEAD + 포인터 상태 새로고침 + 기록된 포인터와 일치 + 기록된 포인터와 불일치 + 이 커밋에서 gitlink 삭제됨 + 조회 불가 + 상위 커밋으로 이동 + 펼치기 + 펼침 + 접힘 + 펼침 한도 도달 + 접기 + 이 영역과 펼쳐진 하위 영역을 함께 접습니다 diff --git a/src/ViewModels/Histories.Submodules.cs b/src/ViewModels/Histories.Submodules.cs new file mode 100644 index 0000000000..049a024767 --- /dev/null +++ b/src/ViewModels/Histories.Submodules.cs @@ -0,0 +1,582 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class SubmoduleCommitDetail : ObservableObject + { + public string Path { get; init; } + public Models.SubmoduleHistory Section { get; init; } + public string PointerStatus + { + get => _pointerStatus; + set => SetProperty(ref _pointerStatus, value); + } + private string _pointerStatus = string.Empty; + public string Text + { + get => _text; + set => SetProperty(ref _text, value); + } + private string _text = string.Empty; + } + + public class SubmoduleComparisonDetail : SubmoduleCommitDetail + { + public List PreviousPointers { get; init; } + public string NewPointer { get; init; } + public Action Compare { get; init; } + public string SelectedPrevious + { + get => _selectedPrevious; + set + { + if (SetProperty(ref _selectedPrevious, value) && value != null) + Compare(value); + } + } + public SubmoduleComparisonDetail(string selected) => _selectedPrevious = selected; + private string _selectedPrevious; + } + + public class SubmoduleNavigationItem + { + public string Label { get; init; } + public string Separator { get; init; } + public string GraphKey { get; init; } + public bool IsAvailable { get; init; } + } + + public partial class Histories + { + private Models.SubmoduleHistory NavigationSection => + (_detailContext as SubmoduleCommitDetail)?.Section ?? _selectedCommits.FirstOrDefault()?.SubmoduleHistory; + + public bool CanNavigateSubmoduleUp => NavigationSection != null; + + public List SubmoduleNavigation + { + get + { + var chain = new List(); + for (var section = NavigationSection; section != null; section = section.ParentHistory) + chain.Add(section); + chain.Reverse(); + var result = new List(); + if (chain.Count == 0) + return result; + var rootName = System.IO.Path.GetFileName(_repo.FullPath.TrimEnd('/', '\\')); + Add(rootName, chain[0].OwnerKey); + for (var i = 0; i < chain.Count; i++) + { + var section = chain[i]; + var selected = _selectedCommits.FirstOrDefault(); + var target = i + 1 < chain.Count ? chain[i + 1].OwnerKey : + selected?.SubmoduleHistory?.Key == section.Key ? selected.GraphKey : + section.GraphKey(section.NewSHA ?? section.Previous.Keys.FirstOrDefault()); + Add(section.LocalPath ?? section.Path, target); + } + return result; + + void Add(string label, string key) => result.Add(new SubmoduleNavigationItem + { + Label = label, + Separator = result.Count == 0 ? string.Empty : "→", + GraphKey = key, + IsAvailable = _commits.Any(c => c.GraphKey == key), + }); + } + } + + private void NotifySubmoduleNavigation() + { + OnPropertyChanged(nameof(SubmoduleNavigation)); + OnPropertyChanged(nameof(CanNavigateSubmoduleUp)); + } + + public void NavigateSubmoduleUp() + { + if (NavigationSection != null) + NavigateSubmoduleTo(NavigationSection.OwnerKey); + } + + public void NavigateSubmoduleTo(string key) + { + var target = _commits.Find(c => c.GraphKey == key); + if (target == null) + return; + CancelSubmoduleHistory(); + _ignoreSelectionChange = true; + try + { + SelectedCommits = [target]; + } + finally + { + _ignoreSelectionChange = false; + } + SubmoduleStatus = string.Empty; + SubmoduleChanges = _knownSubmodules.Values.Where(s => s.OwnerKey == key).ToList(); + if (target.IsSubmodulePlaceholder) + _ = CompareSubmoduleAsync(target.SubmoduleHistory, target.SubmoduleHistory.Previous.Keys.FirstOrDefault()); + else if (target.SubmoduleHistory != null) + _ = ShowSubmoduleCommitAsync(target, false); + else + DetailContext = new CommitDetail(_repo, _commitDetailSharedData) { Commit = target }; + NotifySubmoduleNavigation(); + } + + public List SubmoduleChanges + { + get => _submoduleChanges; + private set => SetProperty(ref _submoduleChanges, value); + } + + public string SubmoduleStatus + { + get => _submoduleStatus; + private set => SetProperty(ref _submoduleStatus, value); + } + + public string SubmoduleExpansionCount => $"{_expandedSubmodules.Count} / {Preferences.Instance.MaxExpandedSubmoduleHistories}"; + + public List ExpandedSubmodules => new(_expandedSubmodules); + public bool HasExpandedSubmodules => _expandedSubmodules.Count > 0; + + public List GetSubmoduleHistoryChoices() => + _knownSubmodules.Values.Where(s => _commits.Any(c => c.GraphKey == s.HeaderKey)).ToList(); + + private void NotifyExpandedSubmodules() + { + OnPropertyChanged(nameof(SubmoduleExpansionCount)); + OnPropertyChanged(nameof(ExpandedSubmodules)); + OnPropertyChanged(nameof(HasExpandedSubmodules)); + } + + public void CollapseSubmoduleHistory(Models.SubmoduleHistory section) + { + // Keep the first real commit visible while hiding the rest of the area. + if (!IsSubmoduleExpanded(section)) + return; + CancelSubmoduleHistory(); + _expandedSubmodules.RemoveAll(x => IsWithin(x, section)); + SubmoduleStatus = string.Empty; + RebuildSubmoduleGraph(); + SelectSubmoduleHeader(section); + } + + public bool IsSubmoduleExpanded(Models.SubmoduleHistory section) => _expandedSubmodules.Any(x => x.Key == section.Key); + + public void CancelSubmoduleHistory() + { + _expandingCommit = null; + _pointerStatusRequest?.Cancel(); + _pointerStatusRequest?.Dispose(); + _pointerStatusRequest = null; + _submoduleRequest?.Cancel(); + _submoduleRequest?.Dispose(); + _submoduleRequest = null; + } + + public void CollapseSubmoduleHistories() + { + CancelSubmoduleHistory(); + _expandedSubmodules.Clear(); + SubmoduleStatus = string.Empty; + RebuildSubmoduleGraph(); + } + + public void EnsureSubmodulePreferences() + { + if (_submoduleScope != Preferences.Instance.SubmoduleHistoryScope || + _submoduleMode != Preferences.Instance.SubmoduleHistoryMode || + _submoduleLimit != Preferences.Instance.MaxExpandedSubmoduleHistories || + SubmoduleStatus == App.Text("SubmoduleHistory.Loading")) + ResetSubmoduleHistories(); + else if (_submoduleLabelFormat != Preferences.Instance.SubmoduleHistoryLabelFormat) + UpdateSubmoduleLabels(); + } + + public void UpdateSubmoduleLabels() + { + _submoduleLabelFormat = Preferences.Instance.SubmoduleHistoryLabelFormat; + RebuildSubmoduleGraph(); + } + + public void ResetSubmoduleHistories() + { + _submoduleLabelFormat = Preferences.Instance.SubmoduleHistoryLabelFormat; + _submoduleScope = Preferences.Instance.SubmoduleHistoryScope; + _submoduleMode = Preferences.Instance.SubmoduleHistoryMode; + _submoduleLimit = Preferences.Instance.MaxExpandedSubmoduleHistories; + var selected = _selectedCommits.FirstOrDefault(); + _knownSubmodules.Clear(); + _hiddenSubmoduleOwners.Clear(); + CollapseSubmoduleHistories(); + if (selected?.SubmoduleHistory != null) + { + var top = selected.SubmoduleHistory; + while (top.ParentHistory != null) + top = top.ParentHistory; + selected = _rootCommits.Find(c => c.SHA == top.OwnerSHA); + } + if (selected != null) + { + var displayed = _commits.Find(c => c.GraphKey == selected.GraphKey); + if (displayed != null) + SelectedCommits = [displayed]; + } + } + + public async Task ToggleSubmoduleHistoryAsync(Models.SubmoduleHistory section) + { + if (!_commits.Any(c => c.GraphKey == section.HeaderKey)) + return; + CancelSubmoduleHistory(); + if (IsSubmoduleExpanded(section)) + { + CollapseSubmoduleHistory(section); + return; + } + if (section.ParentHistory != null && + (Preferences.Instance.SubmoduleHistoryScope != Models.SubmoduleHistoryScope.Recursive || + !IsSubmoduleExpanded(section.ParentHistory))) + return; + var replaced = GetSubmoduleAreasToReplace(section); + if (_expandedSubmodules.Count(s => !replaced.Any(r => IsWithin(s, r))) >= Preferences.Instance.MaxExpandedSubmoduleHistories) + { + SubmoduleStatus = App.Text("SubmoduleHistory.Limit"); + RebuildSubmoduleGraph(); + return; + } + _submoduleRequest = new CancellationTokenSource(); + var token = _submoduleRequest.Token; + SubmoduleStatus = string.Empty; + try + { + await LoadSubmoduleAsync(section, token, replaced); + token.ThrowIfCancellationRequested(); + RebuildSubmoduleGraph(); + if (IsSubmoduleExpanded(section)) + { + CancelSubmoduleHistory(); + SelectSubmoduleHeader(section); + } + } + catch (OperationCanceledException) { } + finally { RebuildSubmoduleGraph(); } + } + + private List GetSubmoduleAreasToReplace(Models.SubmoduleHistory section) => + Preferences.Instance.AutoCollapseSubmoduleHistories + ? _expandedSubmodules.Where(s => s.Key != section.Key && s.ParentHistory?.Key == section.ParentHistory?.Key).ToList() + : []; + + private async Task DiscoverSelectedSubmodulesAsync(Models.Commit commit, CancellationToken? requestToken = null) + { + if (!requestToken.HasValue) + _submoduleRequest = new CancellationTokenSource(); + var token = requestToken ?? _submoduleRequest.Token; + _expandingCommit = commit.GraphKey; + SubmoduleStatus = App.Text("SubmoduleHistory.Loading"); + try + { + // Rapid keyboard navigation should not start one Git traversal per row. + await Task.Delay(120, token); + var changes = await new Commands.QuerySubmoduleHistory(commit.SubmoduleHistory?.RepositoryPath ?? _repo.FullPath, token).GetChangesAsync(commit); + token.ThrowIfCancellationRequested(); + changes = changes.Select(section => + { + if (_knownSubmodules.TryGetValue(section.Key, out var known)) + return known; + _knownSubmodules.Add(section.Key, section); + return section; + }).ToList(); + _hiddenSubmoduleOwners.Remove(commit.GraphKey); + SubmoduleChanges = changes; + RebuildSubmoduleGraph(); + foreach (var section in changes) + { + if (section.HeaderCommit == null) + await new Commands.QuerySubmoduleHistory(commit.SubmoduleHistory?.RepositoryPath ?? _repo.FullPath, token).ReadPreviewAsync(section); + } + token.ThrowIfCancellationRequested(); + SubmoduleStatus = string.Empty; + if (Preferences.Instance.AutoExpandSubmoduleHistory) + { + foreach (var section in changes) + { + if (IsSubmoduleExpanded(section)) + { + if (Preferences.Instance.AutoCollapseSubmoduleHistories) + break; + continue; + } + var replaced = GetSubmoduleAreasToReplace(section); + if (_expandedSubmodules.Count(s => !replaced.Any(r => IsWithin(s, r))) >= Preferences.Instance.MaxExpandedSubmoduleHistories) + break; + await LoadSubmoduleAsync(section, token, replaced); + // Auto-collapse and auto-expand together open the first available changed area. + if (Preferences.Instance.AutoCollapseSubmoduleHistories && IsSubmoduleExpanded(section)) + break; + } + } + token.ThrowIfCancellationRequested(); + RebuildSubmoduleGraph(); + } + catch (OperationCanceledException) { } + catch (Exception) + { + if (!token.IsCancellationRequested) + SubmoduleStatus = App.Text("SubmoduleHistory.QueryFailed"); + } + finally + { + if (_submoduleRequest?.Token == token) + _expandingCommit = null; + RebuildSubmoduleGraph(); + } + } + + private async Task LoadSubmoduleAsync(Models.SubmoduleHistory section, CancellationToken token, List replaced) + { + section.IsLoading = true; + RebuildSubmoduleGraph(); + try + { + await new Commands.QuerySubmoduleHistory(section.ParentHistory?.RepositoryPath ?? _repo.FullPath, token).LoadAsync( + section, Preferences.Instance.SubmoduleHistoryMode, Preferences.Instance.MaxHistoryCommits); + token.ThrowIfCancellationRequested(); + } + finally + { + section.IsLoading = false; + } + if (section.Error.Length > 0) + SubmoduleStatus = $"{section.Path}: {App.Text(section.Error)}"; + else + { + _expandedSubmodules.RemoveAll(s => replaced.Any(r => IsWithin(s, r))); + _expandedSubmodules.Add(section); + if (section.IsTruncated) + SubmoduleStatus = App.Text("SubmoduleHistory.Truncated"); + } + } + + public List GetSubmoduleComparisonChoices() + { + var choices = new List(); + var selected = _selectedCommits.FirstOrDefault()?.SubmoduleHistory; + if (selected != null) + choices.Add(selected); + choices.AddRange(SubmoduleChanges.Where(c => choices.All(x => x.Key != c.Key))); + return choices; + } + + public async Task CompareSubmoduleAsync(Models.SubmoduleHistory section, string previousSHA) + { + CancelSubmoduleHistory(); + _submoduleRequest = new CancellationTokenSource(); + var token = _submoduleRequest.Token; + var absent = App.Text("SubmoduleHistory.NoPointer"); + var detail = new SubmoduleComparisonDetail(previousSHA ?? absent) + { + Path = section.Path, + Section = section, + NewPointer = section.NewSHA ?? absent, + PreviousPointers = section.Previous.Count == 0 ? [absent] : section.Previous.Keys.ToList(), + Compare = value => _ = CompareSubmoduleAsync(section, value == absent ? null : value), + Text = App.Text("SubmoduleHistory.Loading"), + }; + DetailContext = detail; + _ = RefreshSubmodulePointerStatusAsync(detail); + try + { + var result = await new Commands.QuerySubmoduleHistory(section.ParentHistory?.RepositoryPath ?? _repo.FullPath, token) + .CompareAsync(section, previousSHA, Preferences.Instance.MaxHistoryCommits); + token.ThrowIfCancellationRequested(); + detail.Text = $"{App.Text("SubmoduleHistory.AddedCommits")}\n{result.Added}\n" + + $"{App.Text("SubmoduleHistory.RemovedCommits")}\n{result.Removed}\n" + + $"{App.Text("SubmoduleHistory.FileDiff")}\n{result.Patch}"; + } + catch (OperationCanceledException) { } + catch (Exception e) + { + if (!token.IsCancellationRequested) + detail.Text = App.Text(e.Message == "SubmoduleHistory.Unavailable" ? e.Message : "SubmoduleHistory.MissingObjects"); + } + } + + private async Task ShowSubmoduleCommitAsync(Models.Commit commit, bool expand = true) + { + _submoduleRequest = new CancellationTokenSource(); + var token = _submoduleRequest.Token; + var detail = new SubmoduleCommitDetail { Path = commit.SubmoduleHistory.Path, Section = commit.SubmoduleHistory, Text = App.Text("SubmoduleHistory.Loading") }; + DetailContext = detail; + _ = RefreshSubmodulePointerStatusAsync(detail); + try + { + var text = await new Commands.QuerySubmoduleHistory(_repo.FullPath, token) + .ReadCommitAsync(commit.SubmoduleHistory.RepositoryPath, commit.SHA); + token.ThrowIfCancellationRequested(); + detail.Text = text; + if (expand && IsSubmoduleExpanded(commit.SubmoduleHistory) && Preferences.Instance.SubmoduleHistoryScope == Models.SubmoduleHistoryScope.Recursive) + await DiscoverSelectedSubmodulesAsync(commit, token); + } + catch (OperationCanceledException) { } + catch (Exception) + { + if (!token.IsCancellationRequested) + detail.Text = App.Text("SubmoduleHistory.MissingObjects"); + } + } + + public async Task RefreshSubmodulePointerStatusAsync(SubmoduleCommitDetail detail) + { + _pointerStatusRequest?.Cancel(); + _pointerStatusRequest?.Dispose(); + _pointerStatusRequest = new CancellationTokenSource(); + var token = _pointerStatusRequest.Token; + detail.PointerStatus = App.Text("SubmoduleHistory.Loading"); + try + { + var section = detail.Section; + var status = await new Commands.QuerySubmoduleHistory(section.ParentHistory?.RepositoryPath ?? _repo.FullPath, token) + .ReadPointerStatusAsync(section); + token.ThrowIfCancellationRequested(); + detail.PointerStatus = $"{App.Text("SubmoduleHistory.RecordedPointer")}: {section.NewSHA ?? App.Text("SubmoduleHistory.NoPointer")}\n" + + $"{App.Text("SubmoduleHistory.LocalHEAD")}: {status.LocalHEAD ?? App.Text("SubmoduleHistory.Pointer.Unavailable")} — " + + App.Text($"SubmoduleHistory.Pointer.{status.State}"); + } + catch (OperationCanceledException) { } + } + + private void RebuildSubmoduleGraph() + { + foreach (var section in _knownSubmodules.Values) + { + section.IsExpanded = IsSubmoduleExpanded(section); + section.ExpansionState = section.IsLoading || (section.HeaderCommit == null && section.Error.Length == 0) ? App.Text("SubmoduleHistory.Loading") : + section.Error.Length > 0 ? App.Text(section.Error) : + section.IsExpanded ? App.Text(section.IsTruncated ? "SubmoduleHistory.Truncated" : "SubmoduleHistory.Expanded") : + _expandedSubmodules.Count(s => !GetSubmoduleAreasToReplace(section).Any(r => IsWithin(s, r))) >= Preferences.Instance.MaxExpandedSubmoduleHistories + ? $"{App.Text("SubmoduleHistory.LimitShort")} · {SubmoduleExpansionCount}" + : App.Text("SubmoduleHistory.Collapsed"); + } + var keys = _selectedCommits.Select(c => c.GraphKey).ToHashSet(); + var selectedSection = _selectedCommits.FirstOrDefault()?.SubmoduleHistory; + _ignoreSelectionChange = true; + try + { + _commits = Models.SubmoduleHistory.Compose(_rootCommits, _expandedSubmodules, Preferences.Instance.SubmoduleHistoryLabelFormat, _knownSubmodules.Values.Where(s => !_hiddenSubmoduleOwners.Contains(s.OwnerKey)).ToList()); + GenerateGraph(_commits); + OnPropertyChanged(nameof(Commits)); + SelectedCommits = _commits.Where(c => keys.Contains(c.GraphKey)).ToList(); + } + finally + { + _ignoreSelectionChange = false; + } + NotifyExpandedSubmodules(); + NotifySubmoduleNavigation(); + if (_selectedCommits.Count == 0 && selectedSection != null) + { + // Restore the closest visible owner without reopening collapsed descendants. + Models.Commit owner = null; + for (var section = selectedSection; section != null && owner == null; section = section.ParentHistory) + owner = _commits.Find(c => c.GraphKey == section.HeaderKey) ?? _commits.Find(c => c.GraphKey == section.OwnerKey); + SubmoduleChanges = []; + if (owner != null) + { + _ignoreSelectionChange = true; + SelectedCommits = [owner]; + _ignoreSelectionChange = false; + if (owner.IsSubmodulePlaceholder) + _ = CompareSubmoduleAsync(owner.SubmoduleHistory, owner.SubmoduleHistory.Previous.Keys.FirstOrDefault()); + else if (owner.SubmoduleHistory == null) + DetailContext = new CommitDetail(_repo, _commitDetailSharedData) { Commit = owner }; + else + _ = ShowSubmoduleCommitAsync(owner, false); + } + } + } + + private void SelectSubmoduleHeader(Models.SubmoduleHistory section) + { + var row = _commits.Find(c => c.GraphKey == section.HeaderKey); + if (row != null) + { + _ignoreSelectionChange = true; + SelectedCommits = [row]; + _ignoreSelectionChange = false; + if (row.IsSubmodulePlaceholder) + _ = CompareSubmoduleAsync(section, section.Previous.Keys.FirstOrDefault()); + else + _ = ShowSubmoduleCommitAsync(row, false); + } + } + + public async Task ToggleCommitSubmodulesAsync(Models.Commit commit) + { + if (commit.IsSubmodulePlaceholder) + { + await CompareSubmoduleAsync(commit.SubmoduleHistory, commit.SubmoduleHistory.Previous.Keys.FirstOrDefault()); + return; + } + if (commit.IsSubmoduleHeader && !IsSubmoduleExpanded(commit.SubmoduleHistory)) + { + await ShowSubmoduleCommitAsync(commit, false); + return; + } + if (commit.SubmoduleHistory != null && Preferences.Instance.SubmoduleHistoryScope != Models.SubmoduleHistoryScope.Recursive) + return; + var wasLoading = _expandingCommit == commit.GraphKey; + var sections = _knownSubmodules.Values.Where(s => s.OwnerKey == commit.GraphKey).ToList(); + var hide = !_hiddenSubmoduleOwners.Contains(commit.GraphKey) && (sections.Count > 0 || wasLoading); + CancelSubmoduleHistory(); + if (hide) + { + _expandedSubmodules.RemoveAll(s => sections.Any(owner => IsWithin(s, owner))); + _hiddenSubmoduleOwners.Add(commit.GraphKey); + SubmoduleStatus = string.Empty; + RebuildSubmoduleGraph(); + } + else + { + await DiscoverSelectedSubmodulesAsync(commit); + } + } + + public bool HasKnownSubmoduleChanges(Models.Commit commit) => + _knownSubmodules.Values.Any(s => s.OwnerKey == commit.GraphKey); + + private static bool IsWithin(Models.SubmoduleHistory candidate, Models.SubmoduleHistory ancestor) + { + for (var section = candidate; section != null; section = section.ParentHistory) + { + if (section.Key == ancestor.Key) + return true; + } + return false; + } + + private Models.SubmoduleHistoryScope _submoduleScope; + private Models.SubmoduleHistoryLabelFormat _submoduleLabelFormat; + private Models.SubmoduleHistoryMode _submoduleMode; + private int _submoduleLimit; + private List _rootCommits = []; + private readonly List _expandedSubmodules = []; + private readonly Dictionary _knownSubmodules = []; + private readonly HashSet _hiddenSubmoduleOwners = []; + private List _submoduleChanges = []; + private CancellationTokenSource _submoduleRequest; + private CancellationTokenSource _pointerStatusRequest; + private string _submoduleStatus = string.Empty; + private string _expandingCommit; + } +} diff --git a/src/ViewModels/Histories.cs b/src/ViewModels/Histories.cs index 128b53ff9d..7f5bee246f 100644 --- a/src/ViewModels/Histories.cs +++ b/src/ViewModels/Histories.cs @@ -1,8 +1,8 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Threading.Tasks; - using Avalonia.Collections; using Avalonia.Controls; using Avalonia.Threading; @@ -10,7 +10,7 @@ namespace SourceGit.ViewModels { - public class Histories : ObservableObject + public partial class Histories : ObservableObject { public bool IsLoading { @@ -107,6 +107,14 @@ public List Commits get => _commits; set { + CancelSubmoduleHistory(); + _rootCommits = value; + _expandedSubmodules.Clear(); + _knownSubmodules.Clear(); + _hiddenSubmoduleOwners.Clear(); + SubmoduleChanges = []; + SubmoduleStatus = string.Empty; + NotifyExpandedSubmodules(); GenerateGraph(value); if (SetProperty(ref _commits, value)) PostCommitsChanged(); @@ -149,7 +157,10 @@ public object DetailContext set { if (SetProperty(ref _detailContext, value)) + { OnPropertyChanged(nameof(IsOpenAsStandaloneVisible)); + NotifySubmoduleNavigation(); + } } } @@ -296,7 +307,7 @@ public Models.BisectState UpdateBisectInfo() public void NavigateTo(string commitSHA) { - var commit = _commits.Find(x => x.SHA.StartsWith(commitSHA, StringComparison.Ordinal)); + var commit = _commits.Find(x => x.SubmoduleHistory == null && x.SHA.StartsWith(commitSHA, StringComparison.Ordinal)); if (commit != null) { SelectedCommits = [commit]; @@ -502,15 +513,15 @@ private void PostCommitsChanged() var set = new HashSet(); foreach (var c in _selectedCommits) - set.Add(c.SHA); + set.Add(c.GraphKey); var selected = new List(); foreach (var c in _commits) { - if (set.Contains(c.SHA)) + if (set.Contains(c.GraphKey)) { selected.Add(c); - set.Remove(c.SHA); + set.Remove(c.GraphKey); if (set.Count == 0) break; } @@ -524,6 +535,24 @@ private void PostSelectedCommitsChanged() if (_ignoreSelectionChange) return; + CancelSubmoduleHistory(); + SubmoduleChanges = []; + if (_selectedCommits.Exists(c => c.SubmoduleHistory != null)) + { + _searchCommitContext.Selected = null; + if (_selectedCommits.Count == 1) + { + var selected = _selectedCommits[0]; + if (selected.IsSubmodulePlaceholder) + _ = CompareSubmoduleAsync(selected.SubmoduleHistory, selected.SubmoduleHistory.Previous.Keys.FirstOrDefault()); + else + _ = ShowSubmoduleCommitAsync(selected); + } + else + DetailContext = new Models.Count(_selectedCommits.Count); + return; + } + if (_selectedCommits.Count == 0) { _searchCommitContext.Selected = null; @@ -542,6 +571,8 @@ private void PostSelectedCommitsChanged() detail.Commit = c; else DetailContext = new CommitDetail(_repo, _commitDetailSharedData) { Commit = c }; + + _ = DiscoverSelectedSubmodulesAsync(c); } else if (_selectedCommits.Count == 2) { @@ -564,14 +595,14 @@ private void PostSelectedCommitsChanged() private void GenerateGraph(List commits) { - var firstParentOnly = _repo.UIStates.HistoryShowFlags.HasFlag(Models.HistoryShowFlags.FirstParentOnly); + var firstParentOnly = _expandedSubmodules.Count == 0 && _repo.UIStates.HistoryShowFlags.HasFlag(Models.HistoryShowFlags.FirstParentOnly); var highlighting = _repo.UIStates.GraphHighlighting; var extraHeads = new HashSet(); if (highlighting >= Models.CommitGraphHighlighting.SelectedCommitsOnly) { foreach (var c in _selectedCommits) - extraHeads.Add(c.SHA); + extraHeads.Add(c.GraphKey); } Graph = Models.CommitGraph.Generate(commits, firstParentOnly, highlighting, extraHeads); diff --git a/src/ViewModels/Preferences.cs b/src/ViewModels/Preferences.cs index 4803ec4296..98feb5ae04 100644 --- a/src/ViewModels/Preferences.cs +++ b/src/ViewModels/Preferences.cs @@ -135,6 +135,75 @@ public int MaxHistoryCommits set => SetProperty(ref _maxHistoryCommits, value); } + public Models.SubmoduleHistoryScope SubmoduleHistoryScope + { + get => _submoduleHistoryScope; + set + { + if (SetProperty(ref _submoduleHistoryScope, Enum.IsDefined(value) ? value : Models.SubmoduleHistoryScope.DirectOnly)) + OnPropertyChanged(nameof(SubmoduleHistoryScopeIndex)); + } + } + + [JsonIgnore] + public int SubmoduleHistoryScopeIndex + { + get => (int)SubmoduleHistoryScope; + set => SubmoduleHistoryScope = (Models.SubmoduleHistoryScope)value; + } + + public Models.SubmoduleHistoryMode SubmoduleHistoryMode + { + get => _submoduleHistoryMode; + set + { + if (SetProperty(ref _submoduleHistoryMode, Enum.IsDefined(value) ? value : Models.SubmoduleHistoryMode.ChangedRange)) + OnPropertyChanged(nameof(SubmoduleHistoryModeIndex)); + } + } + + [JsonIgnore] + public int SubmoduleHistoryModeIndex + { + get => (int)SubmoduleHistoryMode; + set => SubmoduleHistoryMode = (Models.SubmoduleHistoryMode)value; + } + + public Models.SubmoduleHistoryLabelFormat SubmoduleHistoryLabelFormat + { + get => _submoduleHistoryLabelFormat; + set + { + if (SetProperty(ref _submoduleHistoryLabelFormat, Enum.IsDefined(value) ? value : Models.SubmoduleHistoryLabelFormat.PathAndBranch)) + OnPropertyChanged(nameof(SubmoduleHistoryLabelFormatIndex)); + } + } + + [JsonIgnore] + public int SubmoduleHistoryLabelFormatIndex + { + get => (int)SubmoduleHistoryLabelFormat; + set => SubmoduleHistoryLabelFormat = (Models.SubmoduleHistoryLabelFormat)value; + } + + public bool AutoExpandSubmoduleHistory + { + get => _autoExpandSubmoduleHistory; + set => SetProperty(ref _autoExpandSubmoduleHistory, value); + } + + public bool AutoCollapseSubmoduleHistories + { + get => _autoCollapseSubmoduleHistories; + set => SetProperty(ref _autoCollapseSubmoduleHistories, value); + } + + public int MaxExpandedSubmoduleHistories + { + get => _maxExpandedSubmoduleHistories; + set => SetProperty(ref _maxExpandedSubmoduleHistories, Math.Clamp(value, 1, 20)); + } + public int SubjectGuideLength { get => _subjectGuideLength; @@ -818,6 +887,12 @@ private bool RemoveInvalidRepositoriesRecursive(List collection) private double _zoom = 1.0; private LayoutInfo _layout = new(); + private Models.SubmoduleHistoryLabelFormat _submoduleHistoryLabelFormat; + private Models.SubmoduleHistoryScope _submoduleHistoryScope; + private Models.SubmoduleHistoryMode _submoduleHistoryMode = Models.SubmoduleHistoryMode.ChangedRange; + private int _maxExpandedSubmoduleHistories = 3; + private bool _autoExpandSubmoduleHistory; + private bool _autoCollapseSubmoduleHistories; private int _maxHistoryCommits = 20000; private int _subjectGuideLength = 50; private bool _useFixedTabWidth = true; diff --git a/src/ViewModels/Repository.cs b/src/ViewModels/Repository.cs index aa58226d97..794c81d792 100644 --- a/src/ViewModels/Repository.cs +++ b/src/ViewModels/Repository.cs @@ -479,6 +479,7 @@ public void Close() if (_cancellationRefreshStashes is { IsCancellationRequested: false }) _cancellationRefreshStashes.Cancel(); + _histories?.CancelSubmoduleHistory(); _watcher?.Dispose(); _autoFetchTimer.Dispose(); } diff --git a/src/Views/Histories.axaml b/src/Views/Histories.axaml index a3d24529fd..e7bd7c33c8 100644 --- a/src/Views/Histories.axaml +++ b/src/Views/Histories.axaml @@ -23,7 +23,35 @@ - + + +