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