Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions src/Models/CommitNavigationHistory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using System.Collections.Generic;

namespace SourceGit.Models
{
public class CommitNavigationHistory
{
public bool CanGoBack => _position > 0;
public bool CanGoForward => _position + 1 < _commits.Count;
public string Current => _position >= 0 ? _commits[_position] : null;
public string Previous => CanGoBack ? _commits[_position - 1] : null;
public string Next => CanGoForward ? _commits[_position + 1] : null;

public void Visit(string sha)
{
if (_position >= 0 && _commits[_position] == sha)
return;

_commits.RemoveRange(_position + 1, _commits.Count - _position - 1);
_commits.Add(sha);
_position++;
}

public string Back()
{
return CanGoBack ? _commits[--_position] : null;
}

public string Forward()
{
return CanGoForward ? _commits[++_position] : null;
}

public void Forget(string sha)
{
for (var i = _commits.Count - 1; i >= 0; i--)
{
if (_commits[i] != sha)
continue;

_commits.RemoveAt(i);
if (i <= _position)
_position--;
}
}

private readonly List<string> _commits = [];
private int _position = -1;
}
}
3 changes: 3 additions & 0 deletions src/Resources/Icons.axaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
<ResourceDictionary xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<StreamGeometry x:Key="Icons.Back">M9 2L2 9L9 16L11 14L7 10H18V8H7L11 4Z</StreamGeometry>
<StreamGeometry x:Key="Icons.Forward">M11 2L18 9L11 16L9 14L13 10H2V8H13L9 4Z</StreamGeometry>
<StreamGeometry x:Key="Icons.Home">M10 1L0 10H3V19H8V13H12V19H17V10H20Z</StreamGeometry>
<StreamGeometry x:Key="Icons.Action">M41 512c0-128 46-241 138-333C271 87 384 41 512 41s241 46 333 138c92 92 138 205 138 333s-46 241-138 333c-92 92-205 138-333 138s-241-46-333-138C87 753 41 640 41 512zm87 0c0 108 36 195 113 271s164 113 271 113c108 0 195-36 271-113s113-164 113-271-36-195-113-271c-77-77-164-113-271-113-108 0-195 36-271 113C164 317 128 404 128 512zm256 148V292l195 113L768 512l-195 113-195 113v-77zm148-113-61 36V440l61 36 61 36-61 36z</StreamGeometry>
<StreamGeometry x:Key="Icons.Address">M306 761c3-3 8-7 16-11 15-7 35-14 60-19 4-1 14-3 11-18l-3-16c-4-18-13-14-18-13C301 699 256 724 256 763c0 58 112 91 252 91 140 0 252-33 252-91 0-38-45-64-116-78-5-1-16-3-18 13-1 6-2 11-3 15-4 18 8 18 14 19 24 5 44 11 58 18 8 4 13 8 16 11 1 2 2 2 2 1 0 3-17 15-50 24-40 12-95 19-155 19-59 0-114-7-155-19-32-9-49-21-50-24 0 0 0-0 2-2zm73-123c54 79 107 142 129 142 22 0 75-62 129-142 62-91 101-173 101-231C738 280 635 176 508 176c-127 0-230 104-230 232 0 57 39 139 101 231zm150 64a446 446 0 01-21 23c-6-6-13-14-21-23-21-25-46-57-68-91-57-84-92-159-92-204 0-102 82-184 182-184 100 0 182 82 182 184 0 45-36 120-92 204-23 33-47 66-68 91zm-21-200a102 102 0 100-204 102 102 0 000 204zm0-48a54 54 0 110-108 54 54 0 010 108z</StreamGeometry>
<StreamGeometry x:Key="Icons.AIAssist">M304 464a128 128 0 01128-128c71 0 128 57 128 128v224a32 32 0 01-64 0V592h-128v95a32 32 0 01-64 0v-224zm64 1v64h128v-64a64 64 0 00-64-64c-35 0-64 29-64 64zM688 337c18 0 32 14 32 32v319a32 32 0 01-32 32c-18 0-32-14-32-32v-319a32 32 0 0132-32zM84 911l60-143A446 446 0 0164 512C64 265 265 64 512 64s448 201 448 448-201 448-448 448c-54 0-105-9-153-27l-242 22a32 32 0 01-32-44zm133-150-53 126 203-18 13 5c41 15 85 23 131 23 212 0 384-172 384-384S724 128 512 128 128 300 128 512c0 82 26 157 69 220l20 29z</StreamGeometry>
Expand Down
3 changes: 3 additions & 0 deletions src/Resources/Locales/en_US.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,9 @@
<x:String x:Key="Text.GitLFS.TrackByExtension" xml:space="preserve">Track all *{0} files</x:String>
<x:String x:Key="Text.GotoRevisionSelector" xml:space="preserve">Select Commit</x:String>
<x:String x:Key="Text.Histories.AdvancedOptions" xml:space="preserve">Advanced Options</x:String>
<x:String x:Key="Text.Histories.NavigateBack" xml:space="preserve">Back</x:String>
<x:String x:Key="Text.Histories.NavigateForward" xml:space="preserve">Forward</x:String>
<x:String x:Key="Text.Histories.NavigateHome" xml:space="preserve">Go to current HEAD</x:String>
<x:String x:Key="Text.Histories.Header.Author" xml:space="preserve">AUTHOR</x:String>
<x:String x:Key="Text.Histories.Header.AuthorTime" xml:space="preserve">AUTHOR TIME</x:String>
<x:String x:Key="Text.Histories.Header.CommitTime" xml:space="preserve">COMMIT TIME</x:String>
Expand Down
160 changes: 160 additions & 0 deletions src/ViewModels/Histories.Navigation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

using Avalonia.Threading;

namespace SourceGit.ViewModels
{
public partial class Histories
{
public bool CanNavigateBack => !_isResolvingNavigation && (_navigationHistory.CanGoBack || HasUnrecordedNavigationSelection);
public bool CanNavigateForward => !_isResolvingNavigation && _navigationHistory.CanGoForward;

public async Task NavigateHomeAsync()
{
CancelNavigationVisit();
var request = _navigationRequest;
// Resolve HEAD in this worktree, even if the loaded history omits it
// or its decorations belong to an earlier checkout.
var commit = await GetCommitAsync("HEAD");
if (request != _navigationRequest)
return;

if (commit == null && _commits.Count > 0)
commit = _commits[0];
if (commit != null)
SelectedCommits = [EnsureNavigationCommitVisible(commit)];
}

public Task NavigateBackAsync() => NavigateHistoryAsync(-1);
public Task NavigateForwardAsync() => NavigateHistoryAsync(1);

public void ResumeNavigationVisits()
{
_isNavigationActive = true;
ScheduleNavigationVisit();
}

public void SuspendNavigationVisits()
{
_isNavigationActive = false;
CancelNavigationVisit();
}

public void CancelNavigationVisit()
{
_navigationRequest++;
_navigationVisitTimer?.Stop();
}

private void ScheduleNavigationVisit()
{
CancelNavigationVisit();
NotifyNavigationChanged();
if (!_isNavigationActive || _isNavigatingHistory || _ignoreSelectionChange || _selectedCommits.Count != 1)
return;

if (_navigationVisitTimer == null)
{
_navigationVisitTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_navigationVisitTimer.Tick += (_, _) =>
{
_navigationVisitTimer.Stop();
if (_selectedCommits.Count == 1)
{
_navigationHistory.Visit(_selectedCommits[0].SHA);
NotifyNavigationChanged();
}
};
}

_navigationVisitTimer.Start();
}

private async Task NavigateHistoryAsync(int direction)
{
if (direction < 0 ? !CanNavigateBack : !CanNavigateForward)
return;

CancelNavigationVisit();
var request = _navigationRequest;
_isResolvingNavigation = true;
NotifyNavigationChanged();
try
{
while (true)
{
var returnToCurrent = direction < 0 && HasUnrecordedNavigationSelection;
var sha = direction < 0 ?
returnToCurrent ? _navigationHistory.Current : _navigationHistory.Previous :
_navigationHistory.Next;
if (sha == null)
return;

var commit = _commits.Find(c => c.SHA == sha);
if (commit == null)
{
commit = await GetCommitAsync(sha);
if (request != _navigationRequest)
return;
if (commit == null)
{
// A rewritten, unreachable commit may have been pruned by Git.
_navigationHistory.Forget(sha);
continue;
}
}

if (!returnToCurrent)
{
if (direction < 0)
_navigationHistory.Back();
else
_navigationHistory.Forward();
}

_isNavigatingHistory = true;
SelectedCommits = [EnsureNavigationCommitVisible(commit)];
return;
}
}
finally
{
_isNavigatingHistory = false;
_isResolvingNavigation = false;
NotifyNavigationChanged();
}
}

private void NotifyNavigationChanged()
{
OnPropertyChanged(nameof(CanNavigateBack));
OnPropertyChanged(nameof(CanNavigateForward));
}

private Models.Commit EnsureNavigationCommitVisible(Models.Commit commit)
{
var existing = _commits.Find(c => c.SHA == commit.SHA);
if (existing != null)
return existing;

var commits = new List<Models.Commit>(_commits);
// Keep children above their parents when restoring an omitted row.
var parentIndex = commits.FindIndex(c => commit.Parents.Contains(c.SHA));
commits.Insert(parentIndex < 0 ? commits.Count : parentIndex, commit);
Commits = commits;
return commit;
}

private bool HasUnrecordedNavigationSelection => _navigationHistory.Current != null &&
(_selectedCommits.Count != 1 || _selectedCommits[0].SHA != _navigationHistory.Current);

private readonly Models.CommitNavigationHistory _navigationHistory = new();
private DispatcherTimer _navigationVisitTimer;
private bool _isNavigatingHistory;
private bool _isResolvingNavigation;
private bool _isNavigationActive;
private int _navigationRequest;
}
}
3 changes: 2 additions & 1 deletion src/ViewModels/Histories.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

namespace SourceGit.ViewModels
{
public class Histories : ObservableObject
public partial class Histories : ObservableObject
{
public bool IsLoading
{
Expand Down Expand Up @@ -526,6 +526,7 @@ private void PostCommitsChanged()

private void PostSelectedCommitsChanged()
{
ScheduleNavigationVisit();
if (_ignoreSelectionChange)
return;

Expand Down
21 changes: 21 additions & 0 deletions src/Views/Histories.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,27 @@
<Path Width="12" Height="12"
Data="{StaticResource Icons.Settings2}"/>
</Button>
<Button x:Name="NavigationHomeButton" Classes="icon_button" Width="18" Height="18" Focusable="False"
Command="{Binding NavigateHomeAsync}">
<ToolTip.Tip>
<TextBlock><Run Text="{DynamicResource Text.Histories.NavigateHome}"/><Run Text=" (Alt+Home)"/></TextBlock>
</ToolTip.Tip>
<Path Width="14" Height="14" Data="{StaticResource Icons.Home}"/>
</Button>
<Button x:Name="NavigationBackButton" Classes="icon_button" Width="18" Height="18" Focusable="False"
Command="{Binding NavigateBackAsync}" IsEnabled="{Binding CanNavigateBack}">
<ToolTip.Tip>
<TextBlock><Run Text="{DynamicResource Text.Histories.NavigateBack}"/><Run Text="{OnPlatform ' (Alt+Left)', macOS=' (⌘+[)'}"/></TextBlock>
</ToolTip.Tip>
<Path Width="14" Height="14" Data="{StaticResource Icons.Back}"/>
</Button>
<Button x:Name="NavigationForwardButton" Classes="icon_button" Width="18" Height="18" Focusable="False"
Command="{Binding NavigateForwardAsync}" IsEnabled="{Binding CanNavigateForward}">
<ToolTip.Tip>
<TextBlock><Run Text="{DynamicResource Text.Histories.NavigateForward}"/><Run Text="{OnPlatform ' (Alt+Right)', macOS=' (⌘+])'}"/></TextBlock>
</ToolTip.Tip>
<Path Width="14" Height="14" Data="{StaticResource Icons.Forward}"/>
</Button>
</StackPanel>
</Border>

Expand Down
41 changes: 35 additions & 6 deletions src/Views/Histories.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -137,12 +137,9 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang

if (change.Property == SelectedCommitsProperty && IsLoaded && !_ignoreSelectionChanged)
{
if (change.OldValue is List<Models.Commit> { Count: 1 } old &&
change.NewValue is List<Models.Commit> { Count: 1 } cur &&
old[0] == cur[0])
ScrollIntoView(old[0], null);
else
ApplySelection();
ApplySelection();
if (change.NewValue is List<Models.Commit> { Count: 1 } selected)
ScrollIntoView(selected[0], null);
}
}

Expand Down Expand Up @@ -372,6 +369,32 @@ public bool IsDetailsPanelExpanded
public Histories()
{
InitializeComponent();
AddHandler(KeyDownEvent, OnNavigationKeyDown, RoutingStrategies.Tunnel);
Loaded += (_, _) => (DataContext as ViewModels.Histories)?.ResumeNavigationVisits();
Unloaded += (_, _) => (DataContext as ViewModels.Histories)?.SuspendNavigationVisits();
}

private async void OnNavigationKeyDown(object sender, KeyEventArgs e)
{
if (DataContext is not ViewModels.Histories vm)
return;

var macCommand = OperatingSystem.IsMacOS() && e.KeyModifiers == KeyModifiers.Meta;
if (e.KeyModifiers == KeyModifiers.Alt && e.Key == Key.Home)
{
e.Handled = true;
await vm.NavigateHomeAsync();
}
else if ((e.KeyModifiers == KeyModifiers.Alt && e.Key == Key.Left) || (macCommand && e.Key == Key.OemOpenBrackets))
{
e.Handled = true;
await vm.NavigateBackAsync();
}
else if ((e.KeyModifiers == KeyModifiers.Alt && e.Key == Key.Right) || (macCommand && e.Key == Key.OemCloseBrackets))
{
e.Handled = true;
await vm.NavigateForwardAsync();
}
}

public async Task GotoParent()
Expand Down Expand Up @@ -462,8 +485,13 @@ public async Task GotoChild()

protected override void OnDataContextChanged(EventArgs e)
{
_navigationHistories?.SuspendNavigationVisits();
base.OnDataContextChanged(e);

_navigationHistories = DataContext as ViewModels.Histories;
if (IsLoaded)
_navigationHistories?.ResumeNavigationVisits();

if (DataContext is ViewModels.Histories vm)
CommitListContainer.Columns[1].Width = new(vm.AuthorColumnWidth, DataGridLengthUnitType.Pixel);
}
Expand Down Expand Up @@ -1964,6 +1992,7 @@ private async Task InteractiveRebaseWithPrefillActionAsync(ViewModels.Repository
await this.ShowDialogAsync(new ViewModels.InteractiveRebase(repo, on, prefill));
}

private ViewModels.Histories _navigationHistories = null;
private Models.Branch _currentBranch = null;
private Models.Bisect _bisect = null;
private bool _hasSingleRemote = false;
Expand Down
11 changes: 11 additions & 0 deletions tests/HistoryNavigationChecks/HistoryNavigationChecks.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../src/SourceGit.csproj" />
<PackageReference Include="Avalonia.Headless" Version="11.3.22" />
</ItemGroup>
</Project>
Loading