From 7e1817bea196961e2777b996ae7ff2340392ac4e Mon Sep 17 00:00:00 2001 From: Tibor Prokai Date: Mon, 26 Oct 2026 14:17:55 +0100 Subject: [PATCH 1/6] Add working-directory history head with shared Local Changes details --- src/Models/Commit.cs | 8 +- src/Models/CommitGraph.cs | 54 ++- src/Resources/Icons.axaml | 1 + src/Resources/Locales/en_US.axaml | 9 + src/ViewModels/Histories.cs | 138 +++++- src/ViewModels/Preferences.cs | 11 + src/ViewModels/Repository.cs | 2 + src/ViewModels/WorkingDirectoryEntry.cs | 53 ++ src/Views/CommitGraph.cs | 3 + src/Views/Histories.axaml | 247 ++++++---- src/Views/Histories.axaml.cs | 54 ++- src/Views/Preferences.axaml | 23 +- src/Views/Repository.axaml | 2 +- src/Views/Repository.axaml.cs | 13 + src/Views/WorkingCopyHost.cs | 51 ++ tests/WorkingDirectoryChecks/Program.cs | 454 ++++++++++++++++++ tests/WorkingDirectoryChecks/README.md | 32 ++ .../WorkingDirectoryChecks.csproj | 10 + 18 files changed, 1061 insertions(+), 104 deletions(-) create mode 100644 src/ViewModels/WorkingDirectoryEntry.cs create mode 100644 src/Views/WorkingCopyHost.cs create mode 100644 tests/WorkingDirectoryChecks/Program.cs create mode 100644 tests/WorkingDirectoryChecks/README.md create mode 100644 tests/WorkingDirectoryChecks/WorkingDirectoryChecks.csproj diff --git a/src/Models/Commit.cs b/src/Models/Commit.cs index de299ed6b7..ef446ac8c0 100644 --- a/src/Models/Commit.cs +++ b/src/Models/Commit.cs @@ -26,7 +26,13 @@ public class Commit : ObservableObject public bool IsMerged { get; set; } = false; public int Color { get; set; } = 0; - public double LeftMargin { get; set; } = 0; + public double LeftMargin + { + get => _leftMargin; + set => SetProperty(ref _leftMargin, value); + } + + private double _leftMargin; public bool IsHighlightedInGraph { diff --git a/src/Models/CommitGraph.cs b/src/Models/CommitGraph.cs index 916a79b1bb..c49dde16a5 100644 --- a/src/Models/CommitGraph.cs +++ b/src/Models/CommitGraph.cs @@ -57,6 +57,7 @@ public enum DotType Default, Head, Merge, + WorkingDirectory, } public class Dot @@ -71,7 +72,7 @@ public class Dot public List Links { get; } = []; public List Dots { get; } = []; - public static CommitGraph Generate(List commits, bool firstParentOnlyEnabled, CommitGraphHighlighting highlighting, HashSet highlightExtraCommits) + public static CommitGraph Generate(List commits, bool firstParentOnlyEnabled, CommitGraphHighlighting highlighting, HashSet highlightExtraCommits, bool hasWorkingDirectory = false, bool reserveHeadLane = true) { const double unitWidth = 12; const double halfWidth = 6; @@ -85,6 +86,17 @@ public static CommitGraph Generate(List commits, bool firstParentOnlyEna var colorPicker = new ColorPicker(); var defHighlighting = highlighting == CommitGraphHighlighting.All; + // Keep HEAD's first-parent ancestry in the first lane, even when other + // branch tips appear above it. The reservation is not a Git commit or + // a visible edge; drawing starts when the actual HEAD is encountered. + PathHelper headLane = null; + var headCommit = commits.Find(x => x.IsCurrentHead); + if (reserveHeadLane && headCommit != null) + { + headLane = new PathHelper(headCommit.SHA, defHighlighting, colorPicker.Next(), new Point(10, -halfHeight)); + unsolved.Add(headLane); + } + foreach (var commit in commits) { PathHelper major = null; @@ -176,6 +188,12 @@ public static CommitGraph Generate(List commits, bool firstParentOnlyEna } commit.IsHighlightedInGraph = isHighlighted; + if (headLane != null && commit.IsCurrentHead && major == headLane) + { + headLane.Path.Points.Clear(); + headLane.Path.Points.Add(new Point(10, offsetY)); + } + // If no path found, create new curve for branch head // Otherwise, create new curve for new merged commit if (major == null) @@ -195,6 +213,9 @@ public static CommitGraph Generate(List commits, bool firstParentOnlyEna temp.Paths.Add(major.Path); } + if (headLane != null && commit.IsCurrentHead && major == headLane && commit.Parents.Count > 0 && !temp.Paths.Contains(major.Path)) + temp.Paths.Add(major.Path); + // Calculate link position of this commit. var position = new Point(major?.LastX ?? offsetX, offsetY); var dotColor = major?.Path.Color ?? 0; @@ -265,6 +286,37 @@ public static CommitGraph Generate(List commits, bool firstParentOnlyEna } unsolved.Clear(); + if (hasWorkingDirectory) + { + // Add a presentation row without moving any of the existing graph lanes. + var offset = new Vector(0, 1); + foreach (var path in temp.Paths) + for (var i = 0; i < path.Points.Count; i++) + path.Points[i] += offset; + foreach (var link in temp.Links) + { + link.Start += offset; + link.Control += offset; + link.End += offset; + } + foreach (var dot in temp.Dots) + dot.Center += offset; + + var working = new Dot { Type = DotType.WorkingDirectory, Center = new Point(10, 0.5), IsHighlighted = true }; + var headIndex = commits.FindIndex(x => x.IsCurrentHead); + if (headIndex >= 0) + { + var head = temp.Dots[headIndex]; + working.Center = new Point(head.Center.X, 0.5); + var edge = new Path(head.Color, true); + edge.Points.Add(working.Center); + edge.Points.Add(head.Center); + temp.Paths.Insert(0, edge); + working.Color = head.Color; + } + temp.Dots.Insert(0, working); + } + return temp; } diff --git a/src/Resources/Icons.axaml b/src/Resources/Icons.axaml index a410aef45f..4b0c83c3ca 100644 --- a/src/Resources/Icons.axaml +++ b/src/Resources/Icons.axaml @@ -109,6 +109,7 @@ M640 96c-158 0-288 130-288 288 0 17 3 31 5 46L105 681 96 691V928h224v-96h96v-96h96v-95c38 18 82 31 128 31 158 0 288-130 288-288s-130-288-288-288zm0 64c123 0 224 101 224 224s-101 224-224 224a235 235 0 01-109-28l-8-4H448v96h-96v96H256v96H160v-146l253-254 12-11-3-17C419 417 416 400 416 384c0-123 101-224 224-224zm64 96a64 64 0 100 128 64 64 0 100-128z M544 85c49 0 90 37 95 85h75a96 96 0 0196 89L811 267a32 32 0 01-28 32L779 299a32 32 0 01-32-28L747 267a32 32 0 00-28-32L715 235h-91a96 96 0 01-80 42H395c-33 0-62-17-80-42L224 235a32 32 0 00-32 28L192 267v576c0 16 12 30 28 32l4 0h128a32 32 0 0132 28l0 4a32 32 0 01-32 32h-128a96 96 0 01-96-89L128 843V267a96 96 0 0189-96L224 171h75a96 96 0 0195-85h150zm256 256a96 96 0 0196 89l0 7v405a96 96 0 01-89 96L800 939h-277a96 96 0 01-96-89L427 843v-405a96 96 0 0189-96L523 341h277zm-256-192H395a32 32 0 000 64h150a32 32 0 100-64z M470 722q-23 3-43 3T384 722v-150l-106 106q-34-26-60-59L324 512H174q-3-23-3-43t3-42h150L218 320q16-20 28-32t32-27L384 367V217q23-4 43-4t43 4v150l106-106q34 26 60 59l-106 107h150q3 22 3 42T680 512h-150l106 107q-16 20-28 32t-32 27l-106-106v150zM0 811q0-36 25-61t61-25 61 25 25 61-25 61-61 25-61-25T0 811z + M652 157a113 113 0 11156 161L731 395 572 236l80-80 1 1zM334 792v0H175v-159l358-358 159 159-358 358z M576 64H448v384H64v128h384v384h128V576h384V448H576z M432 0h160c27 0 48 21 48 48v336h175c36 0 53 43 28 68L539 757c-15 15-40 15-55 0L180 452c-25-25-7-68 28-68H384V48c0-27 21-48 48-48zm592 752v224c0 27-21 48-48 48H48c-27 0-48-21-48-48V752c0-27 21-48 48-48h293l98 98c40 40 105 40 145 0l98-98H976c27 0 48 21 48 48zm-248 176c0-22-18-40-40-40s-40 18-40 40s18 40 40 40s40-18 40-40zm128 0c0-22-18-40-40-40s-40 18-40 40s18 40 40 40s40-18 40-40z M592 768h-160c-27 0-48-21-48-48V384h-175c-36 0-53-43-28-68L485 11c15-15 40-15 55 0l304 304c25 25 7 68-28 68H640v336c0 27-21 48-48 48zm432-16v224c0 27-21 48-48 48H48c-27 0-48-21-48-48V752c0-27 21-48 48-48h272v16c0 62 50 112 112 112h160c62 0 112-50 112-112v-16h272c27 0 48 21 48 48zm-248 176c0-22-18-40-40-40s-40 18-40 40s18 40 40 40s40-18 40-40zm128 0c0-22-18-40-40-40s-40 18-40 40s18 40 40 40s40-18 40-40z diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index 9abb3bdcc9..06c680a24e 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -686,6 +686,10 @@ Use auto-hide scrollbars Use fixed tab width in titlebar Use native window frame + Working Directory Head + Hide + Show when there are local changes + Show always DIFF/MERGE TOOL Diff Arguments Available variables: $LOCAL, $REMOTE @@ -1089,4 +1093,9 @@ Remove Unlock YES + * Working Directory * + Modified files (including renames, type changes and conflicts) + Added and untracked files + Deleted files + LOCAL CHANGES diff --git a/src/ViewModels/Histories.cs b/src/ViewModels/Histories.cs index 128b53ff9d..44a418f668 100644 --- a/src/ViewModels/Histories.cs +++ b/src/ViewModels/Histories.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.ComponentModel; using System.IO; using System.Threading.Tasks; @@ -10,7 +11,7 @@ namespace SourceGit.ViewModels { - public class Histories : ObservableObject + public class Histories : ObservableObject, IDisposable { public bool IsLoading { @@ -109,10 +110,99 @@ public List Commits { GenerateGraph(value); if (SetProperty(ref _commits, value)) - PostCommitsChanged(); + { + IsUpdatingEntries = true; + Entries.Clear(); + if (IsWorkingDirectoryVisible) + Entries.Add(WorkingDirectory); + Entries.AddRange(value); + IsUpdatingEntries = false; + if (_selectHeadAfterRefresh) + { + _selectHeadAfterRefresh = false; + var head = value.Find(x => x.IsCurrentHead); + SelectedCommits = head == null ? [] : [head]; + if (head == null) + DetailContext = Models.Null.Instance; + } + else + PostCommitsChanged(); + if (IsWorkingDirectorySelected) + SelectedCommits = []; + } + } + } + + public bool IsUpdatingEntries { get; private set; } + + public AvaloniaList Entries { get; } = []; + public WorkingDirectoryEntry WorkingDirectory { get; } = new(); + public WorkingCopy WorkingCopy => _repo.WorkingCopy; + + public bool IsWorkingDirectoryVisible => !_repo.IsBare && Preferences.Instance.WorkingDirectoryHeadMode switch + { + 1 => WorkingDirectory.HasChanges, + 2 => true, + _ => false, + }; + + public bool IsWorkingDirectorySelected + { + get => _isWorkingDirectorySelected; + set + { + if ((value && !IsWorkingDirectoryVisible) || !SetProperty(ref _isWorkingDirectorySelected, value)) + return; + + if (value) + { + _selectHeadAfterRefresh = false; + SelectedCommits = []; + _searchCommitContext.Selected = null; + DetailContext = WorkingCopy; + } + else if (_selectedCommits.Count == 0) + DetailContext = Models.Null.Instance; } } + public void UpdateWorkingDirectory(List changes) + { + WorkingDirectory.SetChanges(changes); + RefreshWorkingDirectoryVisibility(true); + } + + private void RefreshWorkingDirectoryVisibility(bool followHeadAfterRefresh) + { + if (Entries.Contains(WorkingDirectory) == IsWorkingDirectoryVisible) + return; + + OnPropertyChanged(nameof(IsWorkingDirectoryVisible)); + + if (IsWorkingDirectoryVisible) + { + _selectHeadAfterRefresh = false; + Entries.Insert(0, WorkingDirectory); + } + else + { + var wasSelected = IsWorkingDirectorySelected; + IsWorkingDirectorySelected = false; + Entries.Remove(WorkingDirectory); + if (wasSelected) + { + var head = _commits.Find(x => x.IsCurrentHead); + SelectedCommits = head == null ? [] : [head]; + if (head == null) + DetailContext = Models.Null.Instance; + // Status may finish before the history query after committing. Follow + // the newly checked-out HEAD when that query arrives, unless the user navigates. + _selectHeadAfterRefresh = followHeadAfterRefresh; + } + } + GenerateGraph(_commits); + } + public Models.CommitGraph Graph { get => _graph; @@ -137,6 +227,11 @@ public List SelectedCommits get => _selectedCommits; set { + if (value.Count > 0) + { + _selectHeadAfterRefresh = false; + IsWorkingDirectorySelected = false; + } var oldCount = _selectedCommits.Count; if (SetProperty(ref _selectedCommits, value) && oldCount + value.Count > 0) PostSelectedCommitsChanged(); @@ -233,6 +328,30 @@ public Histories(Repository repo) _repo = repo; _commitDetailSharedData = new CommitDetailSharedData(); _searchCommitContext = new SearchCommitContext(repo.FullPath, this); + Preferences.Instance.PropertyChanged += OnPreferencesChanged; + } + + public void Dispose() + { + Preferences.Instance.PropertyChanged -= OnPreferencesChanged; + } + + private void OnPreferencesChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName != nameof(Preferences.WorkingDirectoryHeadMode)) + return; + + _selectHeadAfterRefresh = false; + var graph = Graph; + RefreshWorkingDirectoryVisibility(false); + // Hide also restores the original graph lanes when the repository is clean. + if (ReferenceEquals(graph, Graph)) + GenerateGraph(_commits); + } + + public void CancelPendingHeadSelection() + { + _selectHeadAfterRefresh = false; } public bool HasShowFlag(Models.HistoryShowFlags flag) @@ -296,6 +415,7 @@ public Models.BisectState UpdateBisectInfo() public void NavigateTo(string commitSHA) { + _selectHeadAfterRefresh = false; var commit = _commits.Find(x => x.SHA.StartsWith(commitSHA, StringComparison.Ordinal)); if (commit != null) { @@ -312,6 +432,7 @@ public void NavigateTo(string commitSHA) Dispatcher.UIThread.Post(() => { _ignoreSelectionChange = true; + IsWorkingDirectorySelected = false; SelectedCommits = []; if (_detailContext is CommitDetail detail) @@ -524,6 +645,12 @@ private void PostSelectedCommitsChanged() if (_ignoreSelectionChange) return; + if (IsWorkingDirectorySelected) + { + DetailContext = WorkingCopy; + return; + } + if (_selectedCommits.Count == 0) { _searchCommitContext.Selected = null; @@ -574,9 +701,14 @@ private void GenerateGraph(List commits) extraHeads.Add(c.SHA); } - Graph = Models.CommitGraph.Generate(commits, firstParentOnly, highlighting, extraHeads); + var graph = Models.CommitGraph.Generate(commits, firstParentOnly, highlighting, extraHeads, IsWorkingDirectoryVisible, Preferences.Instance.WorkingDirectoryHeadMode != 0); + if (IsWorkingDirectoryVisible) + WorkingDirectory.LeftMargin = graph.Dots[0].Center.X + 14; + Graph = graph; } + private bool _selectHeadAfterRefresh; + private bool _isWorkingDirectorySelected; private Repository _repo = null; private Models.Branch _currentBranch = null; private bool _hasSingleRemote = false; diff --git a/src/ViewModels/Preferences.cs b/src/ViewModels/Preferences.cs index 4803ec4296..68fe274ad2 100644 --- a/src/ViewModels/Preferences.cs +++ b/src/ViewModels/Preferences.cs @@ -169,6 +169,16 @@ public bool Use24Hours } } + public int WorkingDirectoryHeadMode + { + get => _workingDirectoryHeadMode; + set + { + if (value >= 0 && value <= 2) + SetProperty(ref _workingDirectoryHeadMode, value); + } + } + public bool UseFixedTabWidth { get => _useFixedTabWidth; @@ -820,6 +830,7 @@ private bool RemoveInvalidRepositoriesRecursive(List collection) private int _maxHistoryCommits = 20000; private int _subjectGuideLength = 50; + private int _workingDirectoryHeadMode = 0; private bool _useFixedTabWidth = true; private bool _useAutoHideScrollBars = true; private bool _useGitHubStyleAvatar = true; diff --git a/src/ViewModels/Repository.cs b/src/ViewModels/Repository.cs index 1ea5951a83..5887ee82b6 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.Dispose(); _watcher?.Dispose(); _autoFetchTimer.Dispose(); } @@ -1348,6 +1349,7 @@ public void RefreshWorkingCopyChanges() _workingCopy.SetData(changes); LocalChangesCount = changes.Count; + _histories.UpdateWorkingDirectory(changes); OnPropertyChanged(nameof(InProgressContext)); GetOwnerPage()?.ChangeDirtyState(Models.DirtyState.HasLocalChanges, changes.Count == 0); }); diff --git a/src/ViewModels/WorkingDirectoryEntry.cs b/src/ViewModels/WorkingDirectoryEntry.cs new file mode 100644 index 0000000000..1d9d017328 --- /dev/null +++ b/src/ViewModels/WorkingDirectoryEntry.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + // Presentation-only history entry. It has no revision identity and never enters commit queries. + public class WorkingDirectoryEntry : ObservableObject + { + public string Summary => App.Text("Histories.WorkingDirectory"); + public double LeftMargin { get => _leftMargin; set => SetProperty(ref _leftMargin, value); } + public int Modified { get => _modified; private set => SetProperty(ref _modified, value); } + public int Added { get => _added; private set => SetProperty(ref _added, value); } + public int Deleted { get => _deleted; private set => SetProperty(ref _deleted, value); } + public bool HasChanges => Modified + Added + Deleted > 0; + + public void SetChanges(List changes) + { + var modified = 0; + var added = 0; + var deleted = 0; + var paths = new HashSet(StringComparer.Ordinal); + foreach (var c in changes) + { + if (!paths.Add(c.Path)) + continue; + + // Structural changes and conflicts take precedence over addition/deletion. + // AM/AD remain additions: the index introduces a path absent from HEAD. + // MD is deleted; RD stays modified (the rename still exists in the index). + if (IsStructural(c.Index) || IsStructural(c.WorkTree)) + modified++; + else if (c.Index == Models.ChangeState.Added || c.WorkTree is Models.ChangeState.Added or Models.ChangeState.Untracked) + added++; + else if (c.Index == Models.ChangeState.Deleted || c.WorkTree == Models.ChangeState.Deleted) + deleted++; + else + modified++; + } + Modified = modified; + Added = added; + Deleted = deleted; + } + + private static bool IsStructural(Models.ChangeState state) => state is + Models.ChangeState.Renamed or Models.ChangeState.Copied or Models.ChangeState.TypeChanged or Models.ChangeState.Conflicted; + + private double _leftMargin = 24; + private int _modified; + private int _added; + private int _deleted; + } +} diff --git a/src/Views/CommitGraph.cs b/src/Views/CommitGraph.cs index 88a8217c6b..fbfd3248b7 100644 --- a/src/Views/CommitGraph.cs +++ b/src/Views/CommitGraph.cs @@ -183,6 +183,9 @@ private void DrawAnchors(DrawingContext context, Models.CommitGraph graph, doubl var pen = dot.IsHighlighted ? Models.CommitGraph.Pens[dot.Color] : grayedPen; switch (dot.Type) { + case Models.CommitGraph.DotType.WorkingDirectory: + context.DrawEllipse(dotFill, new Pen(pen.Brush, pen.Thickness, new DashStyle([2, 2], 0)), center, 6, 6); + break; case Models.CommitGraph.DotType.Head: context.DrawEllipse(dotFill, pen, center, 6, 6); context.DrawEllipse(pen.Brush, null, center, 3, 3); diff --git a/src/Views/Histories.axaml b/src/Views/Histories.axaml index a3d24529fd..34e310cfcc 100644 --- a/src/Views/Histories.axaml +++ b/src/Views/Histories.axaml @@ -28,7 +28,7 @@ Classes="static_scrollbar" ScrollViewer.AllowAutoHide="False" Background="Transparent" - ItemsSource="{Binding Commits, Mode=OneWay}" + ItemsSource="{Binding Entries, Mode=OneWay}" TotalCommits="{Binding Commits.Count, Mode=OneWay}" SelectedCommits="{Binding SelectedCommits, Mode=TwoWay}" ColumnHeaderHeight="24" @@ -103,7 +103,7 @@ - + @@ -111,46 +111,71 @@ - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -161,24 +186,31 @@ - - - - - - - - + + + + + + + + + + + + + + + @@ -189,13 +221,20 @@ - - - - + + + + + + + + + + + @@ -206,16 +245,23 @@ - - - - + + + + + + + + + + + @@ -226,16 +272,23 @@ - - - - + + + + + + + + + + + @@ -350,6 +403,26 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/Histories.axaml.cs b/src/Views/Histories.axaml.cs index 846c59d1b1..4f52541a82 100644 --- a/src/Views/Histories.axaml.cs +++ b/src/Views/Histories.axaml.cs @@ -150,9 +150,32 @@ protected override void OnSelectionChanged(SelectionChangedEventArgs e) { base.OnSelectionChanged(e); - if (ItemsSource is not IList items) + if (_ignoreSelectionChanged || DataContext is ViewModels.Histories { IsUpdatingEntries: true }) return; + if (ItemsSource is not IList items) + return; + + if (DataContext is ViewModels.Histories vm) + { + vm.CancelPendingHeadSelection(); + var addedWorking = e.AddedItems.Count == 1 && e.AddedItems.Contains(vm.WorkingDirectory); + var containsWorking = SelectedItems.Contains(vm.WorkingDirectory); + if (containsWorking && SelectedItems.Count > 1) + { + _ignoreSelectionChanged = true; + if (addedWorking) + { + SelectedItems.Clear(); + SelectedItems.Add(vm.WorkingDirectory); + } + else + SelectedItems.Remove(vm.WorkingDirectory); + _ignoreSelectionChanged = false; + } + vm.IsWorkingDirectorySelected = SelectedItems.Contains(vm.WorkingDirectory); + } + var commits = new List(); foreach (var o in SelectedItems) { @@ -170,7 +193,7 @@ protected override void OnSelectionChanged(SelectionChangedEventArgs e) if (firstIndex > 0) { var prev = items[firstIndex - 1]; - if (commits.Contains(prev)) + if (prev is Models.Commit previous && commits.Contains(previous)) ScrollIntoView(e.AddedItems[^1], null); else ScrollIntoView(first, null); @@ -239,7 +262,8 @@ protected override async void OnKeyDown(KeyEventArgs e) } e.Handled = true; - await this.CopyTextAsync(builder.ToString()); + if (builder.Length > 0) + await this.CopyTextAsync(builder.ToString()); } if (!e.Handled) @@ -250,13 +274,14 @@ private void ApplySelection() { _ignoreSelectionChanged = true; - if (SelectedCommits == null || SelectedCommits.Count == 0) + if (DataContext is ViewModels.Histories { IsWorkingDirectorySelected: true } vm) { SelectedItems.Clear(); + SelectedItems.Add(vm.WorkingDirectory); } - else if (SelectedCommits.Count == TotalCommits) + else if (SelectedCommits == null || SelectedCommits.Count == 0) { - SelectAll(); + SelectedItems.Clear(); } else { @@ -790,11 +815,20 @@ private void OnCommitListLayoutUpdated(object _1, EventArgs _2) private void OnScrollToTopPointerPressed(object sender, PointerPressedEventArgs e) { if (DataContext is ViewModels.Histories histories) - CommitListContainer.ScrollIntoView(histories.Commits[0], null); + { + if (histories.Entries.Count > 0) + CommitListContainer.ScrollIntoView(histories.Entries[0], null); + } } private void OnCommitListContextRequested(object sender, ContextRequestedEventArgs e) { + if (e.Source is Control { DataContext: ViewModels.WorkingDirectoryEntry }) + { + e.Handled = true; + return; + } + var repoView = this.FindAncestorOfType(); if (repoView is not { DataContext: ViewModels.Repository repo }) return; @@ -810,6 +844,12 @@ private void OnCommitListContextRequested(object sender, ContextRequestedEventAr commits.Add(c); } + if (commits.Count != selected.Count) + { + e.Handled = true; + return; + } + if (selected.Count > 1) { var menu = CreateContextMenuForMultipleCommits(repo, commits); diff --git a/src/Views/Preferences.axaml b/src/Views/Preferences.axaml index 5a19a248f8..cfe4d540a8 100644 --- a/src/Views/Preferences.axaml +++ b/src/Views/Preferences.axaml @@ -188,7 +188,7 @@ - + - + + + + + + + - - - + diff --git a/src/Views/Repository.axaml.cs b/src/Views/Repository.axaml.cs index 93d77396ec..7524c15716 100644 --- a/src/Views/Repository.axaml.cs +++ b/src/Views/Repository.axaml.cs @@ -76,6 +76,19 @@ public GridLength SidebarWidth } } + internal WorkingCopy SharedWorkingCopy + { + get + { + _sharedWorkingCopy ??= new WorkingCopy(); + if (DataContext is ViewModels.Repository repo) + _sharedWorkingCopy.DataContext = repo.WorkingCopy; + return _sharedWorkingCopy; + } + } + + private WorkingCopy _sharedWorkingCopy; + public Repository() { _sidebarWidth = new GridLength(ViewModels.Preferences.Instance.Layout.RepositorySidebarWidth); diff --git a/src/Views/WorkingCopyHost.cs b/src/Views/WorkingCopyHost.cs new file mode 100644 index 0000000000..d894c20e6c --- /dev/null +++ b/src/Views/WorkingCopyHost.cs @@ -0,0 +1,51 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.VisualTree; + +namespace SourceGit.Views +{ + // Transfer a single editor between the sidebar and history details. Only one set of + // editor/diff hotkeys can be attached, and editor state survives changing pages. + public class WorkingCopyHost : ContentControl + { + public static readonly StyledProperty ActiveProperty = + AvaloniaProperty.Register(nameof(Active)); + + public bool Active + { + get => GetValue(ActiveProperty); + set => SetValue(ActiveProperty, value); + } + + protected override void OnLoaded(RoutedEventArgs e) + { + base.OnLoaded(e); + UpdateContent(); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == ActiveProperty && IsLoaded) + UpdateContent(); + } + + private void UpdateContent() + { + if (!Active) + { + Content = null; + return; + } + + if (this.FindAncestorOfType() is not { } owner) + return; + + var view = owner.SharedWorkingCopy; + if (view.Parent is WorkingCopyHost previous && previous != this) + previous.Content = null; + Content = view; + } + } +} diff --git a/tests/WorkingDirectoryChecks/Program.cs b/tests/WorkingDirectoryChecks/Program.cs new file mode 100644 index 0000000000..90df3f7065 --- /dev/null +++ b/tests/WorkingDirectoryChecks/Program.cs @@ -0,0 +1,454 @@ +using System.Diagnostics; +using System.Text.Json; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media.Imaging; +using Avalonia.Input; +using Avalonia.Threading; +using Avalonia.VisualTree; +using SourceGit; +using SourceGit.Models; +using SourceGit.ViewModels; +using Views = SourceGit.Views; + +internal static class Program +{ + private static int _checks; + + [STAThread] + private static void Main(string[] args) + { + var fixture = Path.Combine(Path.GetTempPath(), "sourcegit-working-directory-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(fixture); + SourceGit.Native.OS.BasicDirectories.ConfigDir = fixture; + SourceGit.Native.OS.BasicDirectories.CacheDir = fixture; + Repository repo = null; + Window window = null; + try + { + App.BuildAvaloniaApp().SetupWithoutStarting(); + CheckHeadPreference(fixture); + Preferences.Instance.WorkingDirectoryHeadMode = 1; + CheckClassification(); + CheckGraph(); + Git(fixture, "init -b main"); + Git(fixture, "config user.name Test"); + Git(fixture, "config user.email test@example.com"); + File.WriteAllText(Path.Combine(fixture, "tracked.txt"), "initial\n"); + Git(fixture, "add tracked.txt"); + Git(fixture, "commit -m initial"); + File.WriteAllText(Path.Combine(fixture, "tracked.txt"), "edited\n"); + repo = new Repository(false, fixture, Path.Combine(fixture, ".git")); + repo.Open(); + window = new Window { Title = "SourceGit Working Directory Checks", Width = 1100, Height = 800, + Content = new Views.Repository { DataContext = repo } }; + window.Show(); + PumpUntil(() => !repo.Histories.IsLoading && repo.LocalChangesCount == 1); + CheckSelection(repo, window); + CheckStatusPipeline(repo, fixture); + CheckCommitRefreshRace(repo, fixture); + CheckHeadModes(repo, window); + CheckLayouts(repo, window, args.Contains("--render")); + CheckHeadDropdown(args.Contains("--render")); + Console.WriteLine($"PASS: {_checks} working-directory checks"); + if (args.Contains("--ui")) + { + using var cancellation = new CancellationTokenSource(); + window.Closed += (_, _) => cancellation.Cancel(); + Dispatcher.UIThread.MainLoop(cancellation.Token); + } + } + finally + { + window?.Close(); + repo?.Close(); + foreach (var file in Directory.EnumerateFiles(fixture, "*", SearchOption.AllDirectories)) + File.SetAttributes(file, FileAttributes.Normal); + Directory.Delete(fixture, true); + } + } + + private static void CheckHeadPreference(string fixture) + { + var pref = Preferences.Instance; + Check(pref.WorkingDirectoryHeadMode == 0 && new Preferences().WorkingDirectoryHeadMode == 0, "existing preferences default to Hide"); + pref.SetCanModify(); + foreach (var mode in new[] { 0, 1, 2 }) + { + pref.WorkingDirectoryHeadMode = mode; + pref.Save(); + using var json = JsonDocument.Parse(File.ReadAllText(Path.Combine(fixture, "preference.json"))); + Check(json.RootElement.GetProperty("WorkingDirectoryHeadMode").GetInt32() == mode, "all head modes persist through the production serializer"); + } + pref.WorkingDirectoryHeadMode = -1; + Check(pref.WorkingDirectoryHeadMode == 2, "unselected dropdown value is ignored"); + pref.WorkingDirectoryHeadMode = 3; + Check(pref.WorkingDirectoryHeadMode == 2, "invalid stored mode is ignored"); + File.Delete(Path.Combine(fixture, "preference.json")); + } + + private static void CheckHeadModes(Repository repo, Window window) + { + var vm = repo.Histories; + var pref = Preferences.Instance; + var list = window.GetVisualDescendants().OfType().Single(); + using var second = new Histories(repo); + second.Commits = new List(vm.Commits); + second.UpdateWorkingDirectory([new Change { Path = "tracked.txt", WorkTree = ChangeState.Modified }]); + pref.WorkingDirectoryHeadMode = 0; + PumpUntil(() => !vm.Entries.Contains(vm.WorkingDirectory)); + Check(vm.Entries.SequenceEqual(vm.Commits.Cast()) && vm.Graph.Dots.Count == vm.Commits.Count, "Hide contains only real commits and graph nodes"); + Check(!vm.IsWorkingDirectorySelected && vm.DetailContext is CommitDetail && list.SelectedItem is Commit, "hiding selected working directory restores real commit details"); + Check(!second.IsWorkingDirectoryVisible && !second.Entries.Contains(second.WorkingDirectory), "preference changes update every open history"); + vm.UpdateWorkingDirectory([new Change { Path = "new.txt", WorkTree = ChangeState.Untracked }]); + Check(!vm.Entries.Contains(vm.WorkingDirectory), "status refresh cannot reintroduce hidden head"); + vm.Commits = new List(vm.Commits); + Check(vm.Graph.Dots.Count == vm.Commits.Count && !vm.Entries.Contains(vm.WorkingDirectory), "history refresh cannot reintroduce hidden head"); + vm.IsWorkingDirectorySelected = true; + Check(!vm.IsWorkingDirectorySelected, "hidden head cannot be selected programmatically"); + pref.WorkingDirectoryHeadMode = 1; + PumpUntil(() => vm.Entries.Contains(vm.WorkingDirectory)); + Check(vm.WorkingDirectory.Added == 1 && second.Entries.Contains(second.WorkingDirectory), "conditional mode shows latest changes across histories"); + vm.UpdateWorkingDirectory([]); + Check(!vm.IsWorkingDirectoryVisible && vm.Graph.Dots.Count == vm.Commits.Count, "conditional mode removes clean head"); + pref.WorkingDirectoryHeadMode = 2; + PumpUntil(() => vm.Entries.Contains(vm.WorkingDirectory)); + Check(vm.IsWorkingDirectoryVisible && vm.WorkingDirectory.Modified + vm.WorkingDirectory.Added + vm.WorkingDirectory.Deleted == 0, "Always shows a clean working directory"); + list.SelectedItem = vm.WorkingDirectory; + PumpUntil(() => vm.IsWorkingDirectorySelected); + var graph = vm.Graph; + vm.UpdateWorkingDirectory([new Change { Path = "tracked.txt", WorkTree = ChangeState.Modified }]); + vm.UpdateWorkingDirectory([]); + Check(vm.IsWorkingDirectorySelected && ReferenceEquals(vm.DetailContext, repo.WorkingCopy) && ReferenceEquals(graph, vm.Graph), "Always preserves editor selection and graph across dirty/clean transitions"); + vm.Commits = new List(vm.Commits); + Check(vm.Entries.Count == vm.Commits.Count + 1 && vm.Graph.Dots.Count == vm.Entries.Count, "Always survives history refresh"); + var commits = vm.Commits; + vm.Commits = []; + Check(vm.Entries.Count == 1 && vm.Graph.Dots.Count == 1, "Always shows a clean head in empty or filtered history"); + pref.WorkingDirectoryHeadMode = 0; + Check(vm.Entries.Count == 0 && vm.Graph.Dots.Count == 0 && vm.DetailContext is SourceGit.Models.Null, "Hide restores empty history without stale editor details"); + vm.Commits = commits; + using (var bare = new Histories(new Repository(true, repo.FullPath, repo.GitDir))) + { + pref.WorkingDirectoryHeadMode = 2; + bare.Commits = []; + bare.UpdateWorkingDirectory([new Change { Path = "tracked.txt", WorkTree = ChangeState.Modified }]); + Check(!bare.IsWorkingDirectoryVisible && bare.Entries.Count == 0 && bare.Graph.Dots.Count == 0, "bare repositories never get a working directory head"); + } + second.Dispose(); + var closedGraph = second.Graph; + pref.WorkingDirectoryHeadMode = 1; + Check(ReferenceEquals(second.Graph, closedGraph), "closed histories unsubscribe from preferences"); + vm.UpdateWorkingDirectory([new Change { Path = "tracked.txt", WorkTree = ChangeState.Modified }]); + list.SelectedItem = vm.WorkingDirectory; + PumpUntil(() => vm.IsWorkingDirectorySelected); + } + + private static void CheckHeadDropdown(bool render) + { + var pref = Preferences.Instance; + var mode = pref.WorkingDirectoryHeadMode; + var dialog = new Views.Preferences(); + dialog.Show(); + var tabs = dialog.GetVisualDescendants().OfType().First(); + tabs.SelectedIndex = 1; + PumpUntil(() => dialog.GetVisualDescendants().OfType().Any(x => Avalonia.Automation.AutomationProperties.GetName(x) == "Working Directory Head")); + var dropdown = dialog.GetVisualDescendants().OfType().Single(x => Avalonia.Automation.AutomationProperties.GetName(x) == "Working Directory Head"); + Check(dropdown.Items.OfType().Select(x => x.Content as string).SequenceEqual(new[] { "Hide", "Show when there are local changes", "Show always" }), "Appearance dropdown has all three choices in order"); + foreach (var selected in new[] { 0, 1, 2 }) + { + dropdown.SelectedIndex = selected; + Check(pref.WorkingDirectoryHeadMode == selected, "dropdown selection updates the preference"); + } + pref.WorkingDirectoryHeadMode = 0; + Check(dropdown.SelectedIndex == 0, "preference updates the dropdown selection"); + var box = dialog.GetVisualDescendants().OfType().First(x => x.Content as string == App.Text("Preferences.Appearance.UseFixedTabWidth")); + var dropdownPosition = dropdown.TranslatePoint(new Point(), dialog); + var checkboxPosition = box.TranslatePoint(new Point(), dialog); + Check(dropdownPosition.HasValue && checkboxPosition.HasValue && dropdownPosition.Value.Y + dropdown.Bounds.Height <= checkboxPosition.Value.Y, "head dropdown appears above Appearance checkboxes"); + if (render) + { + using var bitmap = new RenderTargetBitmap(new PixelSize((int)dialog.Bounds.Width, (int)dialog.Bounds.Height)); + bitmap.Render(dialog); + bitmap.Save(Path.Combine(AppContext.BaseDirectory, "appearance-working-directory-head.png")); + } + // Hide rather than close: closing Preferences writes global Git configuration. + dialog.Hide(); + pref.WorkingDirectoryHeadMode = mode; + } + + private static void CheckClassification() + { + var entry = new WorkingDirectoryEntry(); + var cases = new (ChangeState Index, ChangeState WorkTree, int Modified, int Added, int Deleted)[] + { + (ChangeState.None, ChangeState.Modified, 1, 0, 0), + (ChangeState.Modified, ChangeState.Modified, 1, 0, 0), + (ChangeState.Added, ChangeState.Modified, 0, 1, 0), + (ChangeState.Added, ChangeState.Deleted, 0, 1, 0), + (ChangeState.None, ChangeState.Untracked, 0, 1, 0), + (ChangeState.Deleted, ChangeState.None, 0, 0, 1), + (ChangeState.Modified, ChangeState.Deleted, 0, 0, 1), + (ChangeState.Renamed, ChangeState.Deleted, 1, 0, 0), + (ChangeState.Added, ChangeState.TypeChanged, 1, 0, 0), + (ChangeState.None, ChangeState.Conflicted, 1, 0, 0), + }; + foreach (var c in cases) + { + entry.SetChanges([new Change { Path = "file", Index = c.Index, WorkTree = c.WorkTree }]); + Check((entry.Modified, entry.Added, entry.Deleted) == (c.Modified, c.Added, c.Deleted), $"classification {c.Index}/{c.WorkTree}"); + } + entry.SetChanges([new Change { Path = "file", Index = ChangeState.Modified, WorkTree = ChangeState.Modified }, + new Change { Path = "file", Index = ChangeState.Modified }]); + Check(entry.Modified == 1, "deduplicate paths"); + Check(entry.Summary == "* Working Directory *", "fixed working-directory label"); + entry.SetChanges([]); + Check(!entry.HasChanges, "clean entry has no changes"); + } + + private static void CheckGraph() + { + CommitGraph.SetDefaultPens(); + foreach (var headType in new[] { DecoratorType.CurrentBranchHead, DecoratorType.CurrentCommitHead }) + { + var head = new Commit { SHA = new string('a', 40), Decorators = [new SourceGit.Models.Decorator { Type = headType }] }; + var tip = new Commit { SHA = new string('b', 40), Parents = [head.SHA] }; + var baseline = CommitGraph.Generate([tip, head], false, CommitGraphHighlighting.All, []); + var margins = new[] { tip.LeftMargin, head.LeftMargin }; + var graph = CommitGraph.Generate([tip, head], false, CommitGraphHighlighting.All, [], true); + Check(graph.Dots[0].Center.X == baseline.Dots[1].Center.X, "working node shares pinned HEAD lane"); + Check(graph.Dots.Skip(1).Select(x => x.Center.X).SequenceEqual(baseline.Dots.Select(x => x.Center.X)), "working row preserves every real node X coordinate"); + Check(tip.LeftMargin == margins[0] && head.LeftMargin == margins[1], "working row preserves commit text margins"); + Check(graph.Paths[0].Points.All(x => x.X == 10), "working edge stays straight in the leftmost lane"); + Check(graph.Dots[1].Center.X > graph.Dots[2].Center.X, "newer branch tip stays right of HEAD"); + Check(graph.Dots.Count == 3 && graph.Dots[0].Type == CommitGraph.DotType.WorkingDirectory, "distinct graph node"); + Check(graph.Dots[2].Type == CommitGraph.DotType.Head, "preserve real HEAD"); + Check(graph.Paths[0].Points[^1] == graph.Dots[2].Center, "connect HEAD below tip"); + Check(graph.Dots[1].Center.Y == 1.5 && graph.Dots[2].Center.Y == 2.5, "row alignment"); + var filtered = CommitGraph.Generate([tip], false, CommitGraphHighlighting.All, [], true); + Check(filtered.Paths.All(x => x.Points[0] != filtered.Dots[0].Center), "filtered HEAD has no working edge"); + } + var sideHead = new Commit { SHA = new string('c', 40), Decorators = [new SourceGit.Models.Decorator { Type = DecoratorType.CurrentBranchHead }] }; + var otherTip = new Commit { SHA = new string('d', 40), Parents = [new string('e', 40)] }; + var sideBaseline = CommitGraph.Generate([otherTip, sideHead], false, CommitGraphHighlighting.All, []); + var sideGraph = CommitGraph.Generate([otherTip, sideHead], false, CommitGraphHighlighting.All, [], true); + Check(sideGraph.Dots[0].Center.X == sideBaseline.Dots[1].Center.X && sideGraph.Dots[0].Center.X < sideGraph.Dots[1].Center.X, "HEAD is leftmost even below another branch tip"); + var newestHead = CommitGraph.Generate([sideHead], false, CommitGraphHighlighting.All, [], true); + Check(newestHead.Paths[0].Points.All(x => x.X == newestHead.Dots[0].Center.X), "newest HEAD uses a vertical edge"); + var legacyHead = new Commit { SHA = "legacy-head", Decorators = [new SourceGit.Models.Decorator { Type = DecoratorType.CurrentBranchHead }] }; + var legacyTip = new Commit { SHA = "legacy-tip", Parents = [legacyHead.SHA] }; + var legacy = CommitGraph.Generate([legacyTip, legacyHead], false, CommitGraphHighlighting.All, [], false, false); + Check(legacy.Dots.Count == 2 && legacy.Dots.All(x => x.Center.X == 10), "Hide restores fork-point lanes without reserving HEAD lane"); + Check(legacy.Dots[0].Center.Y == 0.5 && legacy.Dots[1].Center.Y == 1.5, "Hide restores original row positions"); + CheckPinnedAncestry(); + var empty = CommitGraph.Generate([], false, CommitGraphHighlighting.All, [], true); + Check(empty.Dots.Count == 1 && empty.Paths.Count == 0, "unborn repository graph"); + } + + private static void CheckPinnedAncestry() + { + var root = new Commit { SHA = "root" }; + var parent = new Commit { SHA = "parent", Parents = [root.SHA] }; + var side = new Commit { SHA = "side", Parents = [root.SHA] }; + var merge = new Commit { SHA = "merge", Parents = [parent.SHA, side.SHA] }; + var head = new Commit { SHA = "head", Parents = [merge.SHA], Decorators = [new SourceGit.Models.Decorator { Type = DecoratorType.CurrentBranchHead }] }; + var other = new Commit { SHA = "other", Parents = [root.SHA] }; + var tip = new Commit { SHA = "tip", Parents = [other.SHA] }; + List commits = [tip, head, other, merge, parent, side, root]; + foreach (var dirty in new[] { false, true }) + foreach (var firstParentOnly in new[] { false, true }) + foreach (var highlighting in Enum.GetValues()) + { + var graph = CommitGraph.Generate(commits, firstParentOnly, highlighting, [head.SHA], dirty); + var offset = dirty ? 1 : 0; + Check(new[] { 1, 3, 4, 6 }.All(i => graph.Dots[i + offset].Center.X == 10), "HEAD first-parent ancestry stays leftmost and straight"); + Check(new[] { 0, 2, 5 }.All(i => graph.Dots[i + offset].Center.X > 10), "other branches remain to the right of HEAD ancestry"); + if (!dirty) + Check(graph.Paths.Where(x => x.Color == graph.Dots[1].Color).All(x => x.Points[0].Y >= 1.5), "clean graph has no invented edge above HEAD"); + } + } + + private static void CheckSelection(Repository repo, Window window) + { + var vm = repo.Histories; + var list = window.GetVisualDescendants().OfType().Single(); + Check(vm.Commits.Count == 1 && vm.Entries.Count == 2, "real commit count excludes working row"); + list.SelectedItem = vm.WorkingDirectory; + PumpUntil(() => vm.IsWorkingDirectorySelected); + Check(ReferenceEquals(vm.DetailContext, repo.WorkingCopy) && vm.SelectedCommits.Count == 0, "working selection uses shared model"); + PumpUntil(() => window.GetVisualDescendants().OfType().Any()); + var editor = window.GetVisualDescendants().OfType().Single(); + list.Focus(); + list.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.Down }); + PumpUntil(() => vm.DetailContext is CommitDetail); + Check(!vm.IsWorkingDirectorySelected, "keyboard Down selects a real commit"); + list.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.Up }); + PumpUntil(() => vm.IsWorkingDirectorySelected); + Check(vm.SelectedCommits.Count == 0, "keyboard Up selects working directory exclusively"); + list.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = Key.A, KeyModifiers = KeyModifiers.Control }); + PumpUntil(() => !vm.IsWorkingDirectorySelected); + Check(list.SelectedItems.Count == vm.Commits.Count && vm.SelectedCommits.Count == vm.Commits.Count, "Select All contains only real commits"); + list.SelectedItem = vm.WorkingDirectory; + PumpUntil(() => vm.IsWorkingDirectorySelected); + var graph = vm.Graph; + repo.WorkingCopy.CommitMessage = "Live subject\nBody"; + Check(vm.WorkingDirectory.Summary == "* Working Directory *" && ReferenceEquals(vm.Graph, graph), "draft preserves fixed label and graph"); + repo.SelectedViewIndex = 1; + PumpUntil(() => window.GetVisualDescendants().OfType().Any()); + Check(ReferenceEquals(editor, window.GetVisualDescendants().OfType().Single()), "same editor on sidebar"); + repo.SelectedViewIndex = 0; + PumpUntil(() => window.GetVisualDescendants().OfType().Any()); + Check(ReferenceEquals(editor, window.GetVisualDescendants().OfType().Single()), "same editor in history"); + vm.IsCollapseDetails = true; + PumpUntil(() => !window.GetVisualDescendants().OfType().Any()); + Check(vm.IsWorkingDirectorySelected, "collapse detaches editor while preserving selection"); + vm.IsCollapseDetails = false; + PumpUntil(() => window.GetVisualDescendants().OfType().Any()); + Check(ReferenceEquals(editor, window.GetVisualDescendants().OfType().Single()), "expand restores shared editor"); + list.SelectedItem = vm.Commits[0]; + PumpUntil(() => vm.DetailContext is CommitDetail); + Check(!vm.IsWorkingDirectorySelected, "real commit restores details"); + list.SelectedItem = vm.WorkingDirectory; + PumpUntil(() => vm.IsWorkingDirectorySelected); + vm.NavigateTo(vm.Commits[0].SHA); + PumpUntil(() => list.SelectedItem is Commit); + Check(!vm.IsWorkingDirectorySelected, "programmatic navigation restores real selection"); + list.SelectedItems.Add(vm.WorkingDirectory); + PumpUntil(() => vm.IsWorkingDirectorySelected); + Check(list.SelectedItems.Count == 1 && vm.SelectedCommits.Count == 0, "working row exclusive selection"); + vm.UpdateWorkingDirectory([new Change { Path = "tracked.txt", WorkTree = ChangeState.Modified }, + new Change { Path = "new.txt", WorkTree = ChangeState.Untracked }]); + Check(vm.IsWorkingDirectorySelected && ReferenceEquals(graph, vm.Graph), "count update preserves selection and graph"); + vm.Commits = new List(vm.Commits); + PumpUntil(() => list.SelectedItems.Contains(vm.WorkingDirectory)); + Check(vm.IsWorkingDirectorySelected, "history refresh preserves working selection"); + vm.UpdateWorkingDirectory([]); + PumpUntil(() => vm.DetailContext is CommitDetail); + Check(vm.SelectedCommits.Count == 1 && vm.SelectedCommits[0].IsCurrentHead, "clean transition selects HEAD"); + vm.UpdateWorkingDirectory([new Change { Path = "tracked.txt", WorkTree = ChangeState.Modified }]); + list.SelectedItem = vm.WorkingDirectory; + PumpUntil(() => vm.IsWorkingDirectorySelected); + var commits = vm.Commits; + vm.Commits = []; + Check(vm.Entries.Count == 1 && vm.Graph.Dots.Count == 1, "working entry survives empty or filtered history"); + vm.UpdateWorkingDirectory([]); + PumpUntil(() => vm.DetailContext is SourceGit.Models.Null); + Check(list.SelectedItems.Count == 0, "clean transition without displayed HEAD clears details"); + vm.Commits = commits; + vm.UpdateWorkingDirectory([new Change { Path = "tracked.txt", WorkTree = ChangeState.Modified }]); + list.SelectedItem = vm.WorkingDirectory; + PumpUntil(() => vm.IsWorkingDirectorySelected); + } + + private static void CheckStatusPipeline(Repository repo, string fixture) + { + File.WriteAllText(Path.Combine(fixture, "new.txt"), "added\n"); + repo.RefreshWorkingCopyChanges(); + PumpUntil(() => repo.LocalChangesCount == 2); + Check(repo.Histories.WorkingDirectory.Added == 1 && repo.Histories.WorkingDirectory.Modified == 1, "status snapshot counts untracked once"); + repo.IncludeUntracked = false; + PumpUntil(() => repo.LocalChangesCount == 1); + Check(repo.Histories.WorkingDirectory.Added == 0, "untracked preference shared with badge"); + repo.IncludeUntracked = true; + PumpUntil(() => repo.LocalChangesCount == 2); + Git(fixture, "add new.txt"); + File.AppendAllText(Path.Combine(fixture, "new.txt"), "edited after staging\n"); + repo.RefreshWorkingCopyChanges(); + PumpUntil(() => repo.WorkingCopy.Staged.Any(x => x.Path == "new.txt") && repo.WorkingCopy.Unstaged.Any(x => x.Path == "new.txt")); + Check(repo.Histories.WorkingDirectory.Added == 1 && repo.Histories.WorkingDirectory.Modified == 1, "AM counts as one addition"); + Git(fixture, "mv tracked.txt renamed.txt"); + File.Delete(Path.Combine(fixture, "renamed.txt")); + repo.RefreshWorkingCopyChanges(); + PumpUntil(() => repo.WorkingCopy.Staged.Any(x => x.Index == ChangeState.Renamed)); + Check(repo.Histories.WorkingDirectory.Modified == 1 && repo.Histories.WorkingDirectory.Deleted == 0, "rename plus deletion counts as modified"); + // Restore the isolated fixture to check a real watcher-driven dirty-to-clean transition. + Git(fixture, "reset --hard HEAD"); + repo.RefreshWorkingCopyChanges(); + PumpUntil(() => repo.LocalChangesCount == 0); + Check(!repo.Histories.WorkingDirectory.HasChanges && !repo.Histories.IsWorkingDirectorySelected, "refresh pipeline removes clean row"); + Check(repo.WorkingCopy.CommitMessage == "Live subject\nBody", "status refresh preserves draft"); + File.AppendAllText(Path.Combine(fixture, "tracked.txt"), "dirty again\n"); + repo.RefreshWorkingCopyChanges(); + PumpUntil(() => repo.LocalChangesCount == 1); + Check(repo.Histories.Entries.Count == repo.Histories.Commits.Count + 1, "clean-to-dirty restores row"); + repo.Histories.IsWorkingDirectorySelected = true; + } + + private static void CheckCommitRefreshRace(Repository repo, string fixture) + { + using (repo.LockWatcher()) + { + Git(fixture, "add tracked.txt"); + Git(fixture, "commit -m second"); + // Deliberately deliver the clean status snapshot before the new commit query. + repo.Histories.UpdateWorkingDirectory([]); + repo.RefreshCommits(); + PumpUntil(() => repo.Histories.Commits.Count == 2); + Check(repo.Histories.SelectedCommits is { Count: 1 } selected && selected[0].IsCurrentHead && selected[0].Subject == "second", "commit refresh selects new HEAD after earlier clean status"); + repo.RefreshWorkingCopyChanges(); + PumpUntil(() => repo.LocalChangesCount == 0); + } + File.AppendAllText(Path.Combine(fixture, "tracked.txt"), "preview edit\n"); + repo.RefreshWorkingCopyChanges(); + PumpUntil(() => repo.LocalChangesCount == 1); + repo.Histories.IsWorkingDirectorySelected = true; + } + + private static void CheckLayouts(Repository repo, Window window, bool render) + { + foreach (var horizontal in new[] { false, true }) + foreach (var theme in new[] { "Light", "Dark" }) + foreach (var width in new[] { 1100, 800 }) + { + Preferences.Instance.UseTwoColumnsLayoutInHistories = horizontal; + Preferences.Instance.Theme = theme; + window.Width = width; + // Give native layout/render jobs an opportunity to run. + for (var i = 0; i < 5; i++) + { + Dispatcher.UIThread.RunJobs(); + Thread.Sleep(20); + } + window.Measure(new Size(width, 800)); + window.Arrange(new Rect(0, 0, width, 800)); + var editor = window.GetVisualDescendants().OfType().Single(); + Check(editor.Bounds.Width >= 180 && editor.Bounds.Height > 50, $"editor usable: {theme}, horizontal={horizontal}, width={width}"); + Check(window.GetVisualDescendants().OfType().Count() == 1, "only one attached working copy"); + if (render) + { + using var bitmap = new RenderTargetBitmap(new PixelSize(width, 800)); + bitmap.Render(window); + bitmap.Save(Path.Combine(AppContext.BaseDirectory, $"preview-{theme}-{horizontal}-{width}.png")); + } + } + } + + private static void PumpUntil(Func condition) + { + var timer = Stopwatch.StartNew(); + do + { + Dispatcher.UIThread.RunJobs(); + Thread.Sleep(20); + if (timer.Elapsed.TotalSeconds > 15) + throw new Exception("Timed out waiting for UI state"); + } while (!condition()); + Dispatcher.UIThread.RunJobs(); + } + + private static void Check(bool condition, string name) + { + if (!condition) + throw new Exception("FAIL: " + name); + _checks++; + } + + private static void Git(string path, string args) + { + using var process = Process.Start(new ProcessStartInfo("git", args) { WorkingDirectory = path, + UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true }); + process.WaitForExit(); + if (process.ExitCode != 0) + throw new Exception(process.StandardError.ReadToEnd()); + } +} diff --git a/tests/WorkingDirectoryChecks/README.md b/tests/WorkingDirectoryChecks/README.md new file mode 100644 index 0000000000..f222fb07e8 --- /dev/null +++ b/tests/WorkingDirectoryChecks/README.md @@ -0,0 +1,32 @@ +# Working-directory regression checks + +This standalone executable exercises the actual Avalonia controls, view models, +commit graph, and Git status refresh pipeline without additional test packages. +It uses a temporary repository and temporary preferences, and removes its fixture +when it finishes. Run it on a desktop with the application prerequisites installed. + +To avoid locking the running SourceGit application's output, build separately: + +```powershell +dotnet build tests/WorkingDirectoryChecks/WorkingDirectoryChecks.csproj -o build/working-directory-checks +./build/working-directory-checks/WorkingDirectoryChecks.exe +``` + +Pass `--render` to save light/dark renders for both history orientations at 800 and +1100 pixels wide beside the executable. Pass `--ui` to leave the fixture window +open for inspection; close it to finish cleanup. Remove the separate output folder +after validation. In restricted environments, `-p:UsedAvaloniaProducts=` disables +the build-services telemetry task that otherwise writes outside the workspace. + +Coverage includes status classification and deduplication, untracked-file settings, +staged additions edited again, renames with deletion, clean/dirty transitions, +draft preservation, keyboard navigation, exclusive selection, real-commit counts, +programmatic navigation, empty/filtered history, attached/detached HEAD edges, pinned leftmost first-parent ancestry, +shared editor lifecycle, collapsed details, all three Working Directory Head modes, +live preference changes, persisted mode values, original graph lanes in Hide mode, +bare repositories, the Appearance dropdown, and theme/orientation rendering. +Push and interactive merge/rebase workflows require additional manual testing. + +Working Directory Head defaults to Hide. The tests opt into conditional display +for the existing graph-head scenarios, then exercise Hide and Always separately. +The `--render` option also saves the Appearance tab with the new dropdown. diff --git a/tests/WorkingDirectoryChecks/WorkingDirectoryChecks.csproj b/tests/WorkingDirectoryChecks/WorkingDirectoryChecks.csproj new file mode 100644 index 0000000000..fd214d7b72 --- /dev/null +++ b/tests/WorkingDirectoryChecks/WorkingDirectoryChecks.csproj @@ -0,0 +1,10 @@ + + + Exe + net10.0 + enable + + + + + From d2246ddd0686f6d091d3ce15b04e55852e6af38a Mon Sep 17 00:00:00 2001 From: Tibor Prokai Date: Tue, 27 Oct 2026 17:20:28 +0100 Subject: [PATCH 2/6] Fix working-directory history row hit testing --- src/Views/Histories.axaml | 2 +- tests/WorkingDirectoryChecks/Program.cs | 34 +++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/Views/Histories.axaml b/src/Views/Histories.axaml index 34e310cfcc..ef0a465b15 100644 --- a/src/Views/Histories.axaml +++ b/src/Views/Histories.axaml @@ -89,7 +89,7 @@