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