From 2d14a40f5aaec84e8100c0ad5a28268257cc284d Mon Sep 17 00:00:00 2001 From: Denis Blank Date: Wed, 23 Sep 2026 00:10:54 +0200 Subject: [PATCH] feature: scroll past diff edges to switch to adjacent file When the text diff is scrolled to the bottom and the user keeps scrolling down, the next file in the change list is selected. Scrolling up at the top selects the previous file and shows it from its end. Can be toggled with a new button next to "Syntax Highlighting" (enabled by default). --- src/Resources/Locales/en_US.axaml | 1 + src/ViewModels/Preferences.cs | 7 ++ src/Views/ChangeCollectionView.axaml.cs | 48 +++++++++ src/Views/DiffView.axaml | 8 ++ src/Views/TextDiffView.axaml.cs | 125 ++++++++++++++++++++++++ 5 files changed, 189 insertions(+) diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index eb8ff1ca7..3c444eb20 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -381,6 +381,7 @@ Next Difference NO CHANGES OR ONLY EOL CHANGES OLD + Scroll Past Edges to Switch Files Previous Difference Save as Patch Show hidden symbols diff --git a/src/ViewModels/Preferences.cs b/src/ViewModels/Preferences.cs index 4803ec429..287702ed4 100644 --- a/src/ViewModels/Preferences.cs +++ b/src/ViewModels/Preferences.cs @@ -235,6 +235,12 @@ public bool UseSyntaxHighlighting set => SetProperty(ref _useSyntaxHighlighting, value); } + public bool EnableOverscrollFileNavigation + { + get => _enableOverscrollFileNavigation; + set => SetProperty(ref _enableOverscrollFileNavigation, value); + } + public bool IgnoreCRAtEOLInDiff { get => _ignoreCRAtEOLInDiff; @@ -836,6 +842,7 @@ private bool RemoveInvalidRepositoriesRecursive(List collection) private bool _ignoreWhitespaceChangesInDiff = false; private bool _ignoreCRAtEOLInDiff = true; private bool _useSyntaxHighlighting = false; + private bool _enableOverscrollFileNavigation = true; private bool _enableDiffViewWordWrap = false; private bool _showHiddenSymbolsInDiffView = false; private bool _useFullTextDiff = false; diff --git a/src/Views/ChangeCollectionView.axaml.cs b/src/Views/ChangeCollectionView.axaml.cs index 81b7030d6..b986537b8 100644 --- a/src/Views/ChangeCollectionView.axaml.cs +++ b/src/Views/ChangeCollectionView.axaml.cs @@ -264,6 +264,54 @@ public Models.Change GetNextChangeWithoutSelection() return null; } + public Models.Change SelectAdjacentChange(bool next) + { + if (_selection is not { Count: 1, HasFolder: false }) + return null; + + var current = _selection.Changes[0]; + var step = next ? 1 : -1; + Models.Change target = null; + + if (Content is ViewModels.ChangeCollectionAsTree tree) + { + var idx = -1; + for (var i = 0; i < tree.Rows.Count; i++) + { + if (tree.Rows[i].Change == current) + { + idx = i; + break; + } + } + + if (idx < 0) + return null; + + // Walk from the current row in the chosen direction until the first file row; folder rows are skipped. + for (var i = idx + step; i >= 0 && i < tree.Rows.Count; i += step) + { + var row = tree.Rows[i]; + if (!row.IsFolder && row.Change != null) + { + target = row.Change; + break; + } + } + } + else if (_changes != null) + { + var idx = _changes.IndexOf(current); + if (idx >= 0 && idx + step >= 0 && idx + step < _changes.Count) + target = _changes[idx + step]; + } + + if (target != null) + Selection = new ViewModels.ChangeSelection(new List() { target }); + + return target; + } + public void TakeFocus() { var container = this.FindDescendantOfType(); diff --git a/src/Views/DiffView.axaml b/src/Views/DiffView.axaml index ba889d09b..c5b0ceb21 100644 --- a/src/Views/DiffView.axaml +++ b/src/Views/DiffView.axaml @@ -166,6 +166,14 @@ + + + + + { + s_overscroll.WaitingForNewDiff = false; + s_overscroll.Window = null; + s_overscroll.IgnoreUntil = ExtentHeight > ViewportHeight + 1 ? 0 : Environment.TickCount64 + 150; + }, DispatcherPriority.Background); + } + + // Navigated upwards: show the previous file from its end instead of the first change. + if (s_overscroll.PendingScrollToEndPath != null && isSwitchedWindow) + { + var scrollToEnd = Environment.TickCount64 - s_overscroll.SwitchTime < 1000 && + s_overscroll.PendingScrollToEndPath.Equals(ctx.Option.Path, StringComparison.Ordinal); + s_overscroll.PendingScrollToEndPath = null; + + if (scrollToEnd) + { + Dispatcher.UIThread.Post(ScrollToEnd, DispatcherPriority.Background); + return; + } + } + var line = ctx.BlockNavigation.GetCurrentBlock()?.Start ?? 0; if (line == 0) return; @@ -756,6 +783,8 @@ private void OnTextViewPointerChanged(object sender, PointerEventArgs e) private void OnTextViewPointerWheelChanged(object sender, PointerWheelEventArgs e) { + TryNavigateToAdjacentChange(e); + if (DataContext is not ViewModels.TextDiffContext { Option: { IsLocalChange: true } }) return; @@ -766,6 +795,87 @@ private void OnTextViewPointerWheelChanged(object sender, PointerWheelEventArgs Dispatcher.UIThread.Post(() => UpdateSelectedChunk(y)); } + private void TryNavigateToAdjacentChange(PointerWheelEventArgs e) + { + if (!ViewModels.Preferences.Instance.EnableOverscrollFileNavigation) + return; + + if (DataContext is not ViewModels.TextDiffContext ctx) + return; + + // Horizontal-only scrolling (e.g. touchpad) never switches files. + if (e.Delta.Y == 0) + return; + + // Shift + wheel scrolls horizontally; any modifier means the user is not just scrolling down/up. + if (e.KeyModifiers != KeyModifiers.None) + return; + + var now = Environment.TickCount64; + var elapsed = now - s_overscroll.LastWheelTime; + s_overscroll.LastWheelTime = now; + + // Ignore wheel events until the new file is shown (1s fallback for non-text diffs), otherwise they skip it. + if (s_overscroll.WaitingForNewDiff) + { + if (now - s_overscroll.SwitchTime < 1000) + return; + + s_overscroll.WaitingForNewDiff = false; + s_overscroll.PendingScrollToEndPath = null; + s_overscroll.Window = null; + } + + // Give a short file that fits the viewport a moment on screen before a fast wheel spin moves past it. + if (now < s_overscroll.IgnoreUntil) + return; + + var down = e.Delta.Y < 0; + var atEdge = down ? VerticalOffset + ViewportHeight >= ExtentHeight - 1 : VerticalOffset <= 1; + if (!atEdge || down != s_overscroll.IsDown || elapsed > 800) + s_overscroll.Amount = 0; + + s_overscroll.IsDown = down; + if (!atEdge) + return; + + // Diffs that need scrolling require a bit more overscroll, so the end of a long file is not skipped by accident. + var threshold = ExtentHeight > ViewportHeight + 1 ? 2 : 1; + s_overscroll.Amount += Math.Abs(e.Delta.Y); + if (s_overscroll.Amount < threshold) + return; + + s_overscroll.Amount = 0; + + var target = FindChangeCollectionViewOf(ctx.Option.Path)?.SelectAdjacentChange(down); + if (target == null) + return; + + s_overscroll.PendingScrollToEndPath = down ? null : target.Path; + s_overscroll.WaitingForNewDiff = true; + s_overscroll.SwitchTime = now; + s_overscroll.Window = TopLevel.GetTopLevel(this); + e.Handled = true; + } + + private ChangeCollectionView FindChangeCollectionViewOf(string path) + { + for (var parent = this.GetVisualParent(); parent != null; parent = parent.GetVisualParent()) + { + foreach (var child in parent.GetVisualDescendants()) + { + if (child is ChangeCollectionView { IsEffectivelyVisible: true, Selection: { Count: 1 } selection } view && + selection.Changes[0].Path.Equals(path, StringComparison.Ordinal)) + return view; + } + + if (parent is TopLevel) + break; + } + + return null; + } + private void OnTextViewVisualLinesChanged(object sender, EventArgs e) { if (DataContext is not ViewModels.TextDiffContext ctx) @@ -970,6 +1080,21 @@ private async Task CopyAsPatchAsync() private TextLocation _lastSelectStart = TextLocation.Empty; private TextLocation _lastSelectEnd = TextLocation.Empty; private LineStyleTransformer _lineStyleTransformer; + + private class OverscrollState + { + public long LastWheelTime { get; set; } + public double Amount { get; set; } + public bool IsDown { get; set; } + public bool WaitingForNewDiff { get; set; } + public long SwitchTime { get; set; } + public long IgnoreUntil { get; set; } + public TopLevel Window { get; set; } + public string PendingScrollToEndPath { get; set; } + } + + // Shared across presenters, so both sides of a side-by-side diff count as one gesture. + private static readonly OverscrollState s_overscroll = new(); } public class CombinedTextDiffPresenter : ThemedTextDiffPresenter