diff --git a/screenshots/drag_branch_graph.png b/screenshots/drag_branch_graph.png
new file mode 100644
index 0000000000..2735e5ae06
Binary files /dev/null and b/screenshots/drag_branch_graph.png differ
diff --git a/screenshots/drag_branch_menu.png b/screenshots/drag_branch_menu.png
new file mode 100644
index 0000000000..15ff4ea90c
Binary files /dev/null and b/screenshots/drag_branch_menu.png differ
diff --git a/src/Models/Remote.cs b/src/Models/Remote.cs
index 37fcd12a0e..e6ad335fb0 100644
--- a/src/Models/Remote.cs
+++ b/src/Models/Remote.cs
@@ -146,5 +146,77 @@ public bool TryGetCreatePullRequestURL(out string url, string mergeBranch)
return false;
}
+
+ ///
+ /// Builds the URL to create a pull request on this remote that merges `sourceBranch` into `targetBranch`.
+ /// When `sourceRemote` is another remote (e.g. a fork), only GitHub is supported.
+ ///
+ public bool TryGetCreatePullRequestURL(out string url, string sourceBranch, string targetBranch, Remote sourceRemote)
+ {
+ url = null;
+
+ if (!TryGetVisitURL(out var baseURL))
+ return false;
+
+ var host = new Uri(baseURL).Host;
+ var source = HttpUtility.UrlEncode(sourceBranch);
+ var target = HttpUtility.UrlEncode(targetBranch);
+
+ if (sourceRemote != null && sourceRemote != this)
+ {
+ if (!host.Contains("github.com", StringComparison.Ordinal) || !sourceRemote.TryGetVisitURL(out var sourceURL))
+ return false;
+
+ var sourceURI = new Uri(sourceURL);
+ if (!sourceURI.Host.Equals(host, StringComparison.Ordinal))
+ return false;
+
+ var segments = sourceURI.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries);
+ if (segments.Length < 2)
+ return false;
+
+ url = $"{baseURL}/compare/{target}...{HttpUtility.UrlEncode(segments[0])}:{HttpUtility.UrlEncode(segments[1])}:{source}?expand=1";
+ return true;
+ }
+
+ if (host.Contains("github.com", StringComparison.Ordinal))
+ {
+ url = $"{baseURL}/compare/{target}...{source}?expand=1";
+ return true;
+ }
+
+ if (host.Contains("gitlab", StringComparison.Ordinal))
+ {
+ url = $"{baseURL}/-/merge_requests/new?merge_request%5Bsource_branch%5D={source}&merge_request%5Btarget_branch%5D={target}";
+ return true;
+ }
+
+ if (host.Equals("gitee.com", StringComparison.Ordinal))
+ {
+ url = $"{baseURL}/pulls/new?source={source}&target={target}";
+ return true;
+ }
+
+ if (host.Equals("bitbucket.org", StringComparison.Ordinal))
+ {
+ url = $"{baseURL}/pull-requests/new?source={source}&dest={target}";
+ return true;
+ }
+
+ if (host.Equals("gitea.org", StringComparison.Ordinal))
+ {
+ url = $"{baseURL}/compare/{target}...{source}";
+ return true;
+ }
+
+ if (host.Contains("azure.com", StringComparison.Ordinal) ||
+ host.Contains("visualstudio.com", StringComparison.Ordinal))
+ {
+ url = $"{baseURL}/pullrequestcreate?sourceRef={source}&targetRef={target}";
+ return true;
+ }
+
+ return false;
+ }
}
}
diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml
index 603d223a9c..7a7b14f701 100644
--- a/src/Resources/Locales/en_US.axaml
+++ b/src/Resources/Locales/en_US.axaml
@@ -61,6 +61,7 @@
BLAME ON THIS FILE IS NOT SUPPORTED!!!
Checkout ${0}$...
Checkout ${0}$ as worktree...
+ Compare ${0}$ with ${1}$
Compare selected 2 branches
Compare with...
Compare with HEAD
@@ -68,6 +69,7 @@
Copy Branch Name
Create PR...
Create PR for upstream ${0}$...
+ Create PR from ${0}$ into ${1}$...
Custom Action
Delete ${0}$...
Delete selected {0} branches...
diff --git a/src/ViewModels/BranchTreeNode.cs b/src/ViewModels/BranchTreeNode.cs
index 28a49995ce..ad1910b38b 100644
--- a/src/ViewModels/BranchTreeNode.cs
+++ b/src/ViewModels/BranchTreeNode.cs
@@ -34,6 +34,18 @@ public CornerRadius CornerRadius
set => SetProperty(ref _cornerRadius, value);
}
+ public bool IsDragSource
+ {
+ get => _isDragSource;
+ set => SetProperty(ref _isDragSource, value);
+ }
+
+ public bool IsDropTarget
+ {
+ get => _isDropTarget;
+ set => SetProperty(ref _isDropTarget, value);
+ }
+
public bool IsBranch
{
get => Backend is Models.Branch;
@@ -57,6 +69,8 @@ public string BranchesCount
private Models.FilterMode _filterMode = Models.FilterMode.None;
private bool _isExpanded = false;
private CornerRadius _cornerRadius = new CornerRadius(4);
+ private bool _isDragSource = false;
+ private bool _isDropTarget = false;
public class Builder
{
diff --git a/src/Views/BranchDragDrop.cs b/src/Views/BranchDragDrop.cs
new file mode 100644
index 0000000000..dce1e6890a
--- /dev/null
+++ b/src/Views/BranchDragDrop.cs
@@ -0,0 +1,414 @@
+using System;
+using System.Threading.Tasks;
+
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Controls.Primitives.PopupPositioning;
+using Avalonia.Controls.Shapes;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Threading;
+
+namespace SourceGit.Views
+{
+ ///
+ /// Dragging a branch onto another branch (in the branch trees or in the commit graph) opens a context menu with
+ /// the actions between those two branches. This class holds the state of the ongoing drag and the shared visuals:
+ /// a label following the pointer and highlights for the dragged branch and the branch under the pointer.
+ ///
+ public static class BranchDragDrop
+ {
+ public static readonly DataFormat Format = DataFormat.CreateStringApplicationFormat("sourcegit-dnd-branch");
+
+ ///
+ /// Raised when a drag starts or ends, or when the drop target changes.
+ ///
+ public static event Action StateChanged;
+
+ public static Models.Branch Source => s_state?.Branch;
+ public static Models.Branch Target => s_state?.Target;
+
+ public static async Task StartAsync(Control owner, PointerPressedEventArgs pressed, Point pointer, ViewModels.Repository repo, Models.Branch branch, Action highlightSource)
+ {
+ if (s_state != null)
+ return;
+
+ var topLevel = TopLevel.GetTopLevel(owner);
+ if (topLevel == null)
+ return;
+
+ var state = new State(repo, branch, topLevel, highlightSource);
+ s_state = state;
+ state.Begin(owner, owner.TranslatePoint(pointer, state.Overlay) ?? pointer);
+ StateChanged?.Invoke();
+
+ try
+ {
+ var data = new DataTransfer();
+ data.Add(DataTransferItem.Create(Format, branch.FullName));
+ await DragDrop.DoDragDropAsync(pressed, data, DragDropEffects.Move);
+ }
+ finally
+ {
+ state.End();
+ if (s_state == state)
+ s_state = null;
+ StateChanged?.Invoke();
+ }
+ }
+
+ ///
+ /// Updates the drop target from a drag-over event. `key` identifies the visual element showing `target` and
+ /// `highlight` toggles its highlight. Returns false if this drag is not a branch drag.
+ ///
+ public static bool HandleDragOver(DragEventArgs e, ViewModels.Repository repo, Models.Branch target, object key, Action highlight)
+ {
+ if (s_state == null || !e.DataTransfer.Contains(Format))
+ return false;
+
+ if (!CanDrop(repo, target))
+ {
+ target = null;
+ key = null;
+ highlight = null;
+ }
+
+ s_state.SetDropTarget(target, key, highlight);
+ e.DragEffects = target != null ? DragDropEffects.Move : DragDropEffects.None;
+ e.Handled = true;
+ return true;
+ }
+
+ public static void HandleDragLeave()
+ {
+ s_state?.SetDropTarget(null, null, null);
+ }
+
+ ///
+ /// Opens the context menu for the dragged branch and `target` at `point` (relative to `owner`).
+ ///
+ public static void HandleDrop(DragEventArgs e, Control owner, ViewModels.Repository repo, Models.Branch target)
+ {
+ if (s_state == null || e.DataTransfer.TryGetValue(Format) is not { Length: > 0 } sourceFullName)
+ return;
+
+ e.Handled = true;
+ s_state.SetDropTarget(null, null, null);
+
+ if (!CanDrop(repo, target))
+ return;
+
+ var source = repo.Branches.Find(x => x.FullName.Equals(sourceFullName, StringComparison.Ordinal));
+ if (source == null)
+ return;
+
+ var point = e.GetPosition(owner);
+ Dispatcher.UIThread.Post(() =>
+ {
+ var menu = CreateContextMenu(owner, repo, source, target);
+ menu.Placement = PlacementMode.AnchorAndGravity;
+ menu.PlacementAnchor = PopupAnchor.TopLeft;
+ menu.PlacementGravity = PopupGravity.BottomRight;
+ menu.PlacementRect = new Rect(point, new Size(1, 1));
+ menu.Open(owner);
+ });
+ }
+
+ ///
+ /// Finds the branch shown by a decorator in the commit graph. Returns null for tags and detached HEADs.
+ ///
+ public static Models.Branch FindBranch(ViewModels.Repository repo, Models.Decorator decorator)
+ {
+ if (repo == null || decorator == null)
+ return null;
+
+ return decorator.Type switch
+ {
+ Models.DecoratorType.CurrentBranchHead or Models.DecoratorType.LocalBranchHead =>
+ repo.Branches.Find(x => x.IsLocal && x.Name.Equals(decorator.Name, StringComparison.Ordinal)),
+ Models.DecoratorType.RemoteBranchHead =>
+ repo.Branches.Find(x => !x.IsLocal && x.FriendlyName.Equals(decorator.Name, StringComparison.Ordinal)),
+ _ => null,
+ };
+ }
+
+ ///
+ /// Whether a decorator in the commit graph shows `branch`.
+ ///
+ public static bool IsDecoratorOf(Models.Decorator decorator, Models.Branch branch)
+ {
+ if (decorator == null || branch == null)
+ return false;
+
+ return decorator.Type switch
+ {
+ Models.DecoratorType.CurrentBranchHead or Models.DecoratorType.LocalBranchHead =>
+ branch.IsLocal && branch.Name.Equals(decorator.Name, StringComparison.Ordinal),
+ Models.DecoratorType.RemoteBranchHead =>
+ !branch.IsLocal && branch.FriendlyName.Equals(decorator.Name, StringComparison.Ordinal),
+ _ => false,
+ };
+ }
+
+ private static bool CanDrop(ViewModels.Repository repo, Models.Branch target)
+ {
+ return s_state != null &&
+ target != null &&
+ repo == s_state.Repo &&
+ !target.FullName.Equals(s_state.Branch.FullName, StringComparison.Ordinal);
+ }
+
+ private static ContextMenu CreateContextMenu(Control owner, ViewModels.Repository repo, Models.Branch source, Models.Branch target)
+ {
+ var menu = new ContextMenu();
+
+ var compare = new MenuItem();
+ compare.Header = App.Text("BranchCM.CompareBranches", source.FriendlyName, target.FriendlyName);
+ compare.Icon = owner.CreateMenuIcon("Icons.Compare");
+ compare.Click += (_, e) =>
+ {
+ owner.ShowWindow(new ViewModels.Compare(repo, target, source));
+ e.Handled = true;
+ };
+ menu.Items.Add(compare);
+
+ if (!repo.IsBare)
+ {
+ var merge = new MenuItem();
+ merge.Header = App.Text("BranchCM.Merge", source.FriendlyName, target.FriendlyName);
+ merge.Icon = owner.CreateMenuIcon("Icons.Merge");
+ merge.IsEnabled = target.IsCurrent;
+ merge.Click += (_, e) =>
+ {
+ if (repo.CanCreatePopup())
+ repo.ShowPopup(new ViewModels.Merge(repo, source, target.Name, false));
+ e.Handled = true;
+ };
+
+ var rebase = new MenuItem();
+ rebase.Header = App.Text("BranchCM.Rebase", source.FriendlyName, target.FriendlyName);
+ rebase.Icon = owner.CreateMenuIcon("Icons.Rebase");
+ rebase.IsEnabled = source.IsCurrent;
+ rebase.Click += (_, e) =>
+ {
+ if (repo.CanCreatePopup())
+ repo.ShowPopup(new ViewModels.Rebase(repo, source, target));
+ e.Handled = true;
+ };
+
+ var interactiveRebase = new MenuItem();
+ interactiveRebase.Header = App.Text("BranchCM.InteractiveRebase.Manually", source.FriendlyName, target.FriendlyName);
+ interactiveRebase.Icon = owner.CreateMenuIcon("Icons.InteractiveRebase");
+ interactiveRebase.IsEnabled = source.IsCurrent && !source.Head.Equals(target.Head, StringComparison.Ordinal);
+ interactiveRebase.Click += async (_, e) =>
+ {
+ var commit = await new Commands.QuerySingleCommit(repo.FullPath, target.Head).GetResultAsync();
+ await owner.ShowDialogAsync(new ViewModels.InteractiveRebase(repo, commit));
+ e.Handled = true;
+ };
+
+ menu.Items.Add(new MenuItem() { Header = "-" });
+ menu.Items.Add(merge);
+ menu.Items.Add(rebase);
+ menu.Items.Add(interactiveRebase);
+ }
+
+ // A pull request needs both branches on a remote. Local branches are resolved to their upstream.
+ var remoteTarget = target.IsLocal ? FindUpstream(repo, target) : target;
+ var targetRemote = remoteTarget != null ? repo.Remotes.Find(x => x.Name.Equals(remoteTarget.Remote, StringComparison.Ordinal)) : null;
+ if (targetRemote != null)
+ {
+ var remoteSource = source.IsLocal ? FindUpstream(repo, source) : source;
+ var sourceRemote = remoteSource != null ? repo.Remotes.Find(x => x.Name.Equals(remoteSource.Remote, StringComparison.Ordinal)) : null;
+
+ string prURL = null;
+ var canCreatePR = sourceRemote != null &&
+ !remoteSource.FullName.Equals(remoteTarget.FullName, StringComparison.Ordinal) &&
+ targetRemote.TryGetCreatePullRequestURL(out prURL, remoteSource.Name, remoteTarget.Name, sourceRemote);
+
+ // Only offer the action when the target's host supports it. If the source is not pushed yet, show it disabled.
+ if (canCreatePR || (remoteSource == null && targetRemote.TryGetCreatePullRequestURL(out _, source.Name, remoteTarget.Name, null)))
+ {
+ var createPR = new MenuItem();
+ createPR.Header = App.Text("BranchCM.CreatePRFromTo", remoteSource?.FriendlyName ?? source.FriendlyName, remoteTarget.FriendlyName);
+ createPR.Icon = owner.CreateMenuIcon("Icons.CreatePR");
+ createPR.IsEnabled = canCreatePR;
+ createPR.Click += (_, e) =>
+ {
+ Native.OS.OpenBrowser(prURL);
+ e.Handled = true;
+ };
+
+ menu.Items.Add(new MenuItem() { Header = "-" });
+ menu.Items.Add(createPR);
+ }
+ }
+
+ return menu;
+ }
+
+ private static Models.Branch FindUpstream(ViewModels.Repository repo, Models.Branch branch)
+ {
+ if (string.IsNullOrEmpty(branch.Upstream))
+ return null;
+
+ return repo.Branches.Find(x => x.FullName.Equals(branch.Upstream, StringComparison.Ordinal));
+ }
+
+ private class State
+ {
+ public ViewModels.Repository Repo { get; }
+ public Models.Branch Branch { get; }
+ public Models.Branch Target { get; private set; }
+ public OverlayLayer Overlay { get; }
+
+ public State(ViewModels.Repository repo, Models.Branch branch, TopLevel topLevel, Action highlightSource)
+ {
+ Repo = repo;
+ Branch = branch;
+ Overlay = OverlayLayer.GetOverlayLayer(topLevel);
+ _topLevel = topLevel;
+ _highlightSource = highlightSource;
+ }
+
+ public void Begin(Control owner, Point pointer)
+ {
+ _highlightSource?.Invoke(true);
+
+ _label = new TextBlock()
+ {
+ Foreground = Brushes.White,
+ FontWeight = FontWeight.Bold,
+ VerticalAlignment = VerticalAlignment.Center,
+ Margin = new Thickness(6, 0, 0, 0),
+ };
+
+ var icon = new Path()
+ {
+ Width = 12,
+ Height = 12,
+ Fill = Brushes.White,
+ VerticalAlignment = VerticalAlignment.Center,
+ Data = owner.FindResource("Icons.Branch") as StreamGeometry,
+ };
+
+ var panel = new StackPanel() { Orientation = Orientation.Horizontal };
+ panel.Children.Add(icon);
+ panel.Children.Add(_label);
+
+ _ghost = new Border()
+ {
+ Background = owner.FindResource("Brush.Accent") as IBrush ?? Brushes.DodgerBlue,
+ CornerRadius = new CornerRadius(4),
+ Padding = new Thickness(8, 4),
+ BoxShadow = BoxShadows.Parse("0 2 8 0 #60000000"),
+ IsHitTestVisible = false,
+ Child = panel,
+ };
+
+ UpdateLabel();
+ MoveGhost(pointer);
+ Overlay?.Children.Add(_ghost);
+
+ // The drop targets only receive drag events while the pointer is above them. Temporarily accept
+ // drags on the whole window so the label keeps following the pointer everywhere.
+ _topLevelAllowDrop = DragDrop.GetAllowDrop(_topLevel);
+ DragDrop.SetAllowDrop(_topLevel, true);
+ _topLevel.AddHandler(DragDrop.DragOverEvent, OnTopLevelDragOver, RoutingStrategies.Bubble, true);
+ _topLevel.AddHandler(DragDrop.DragLeaveEvent, OnTopLevelDragLeave, RoutingStrategies.Bubble, true);
+ _topLevel.AddHandler(DragDrop.DropEvent, OnTopLevelDrop, RoutingStrategies.Bubble, true);
+ }
+
+ public void End()
+ {
+ _topLevel.RemoveHandler(DragDrop.DragOverEvent, OnTopLevelDragOver);
+ _topLevel.RemoveHandler(DragDrop.DragLeaveEvent, OnTopLevelDragLeave);
+ _topLevel.RemoveHandler(DragDrop.DropEvent, OnTopLevelDrop);
+ DragDrop.SetAllowDrop(_topLevel, _topLevelAllowDrop);
+
+ if (_ghost != null)
+ Overlay?.Children.Remove(_ghost);
+
+ SetDropTarget(null, null, null);
+ _highlightSource?.Invoke(false);
+ }
+
+ public void SetDropTarget(Models.Branch target, object key, Action highlight)
+ {
+ if (_targetKey == key && Target == target)
+ return;
+
+ _highlightTarget?.Invoke(false);
+
+ Target = target;
+ _targetKey = key;
+ _highlightTarget = highlight;
+
+ _highlightTarget?.Invoke(true);
+
+ UpdateLabel();
+ StateChanged?.Invoke();
+ }
+
+ private void UpdateLabel()
+ {
+ if (_label != null)
+ _label.Text = Target != null ? $"{Branch.FriendlyName} → {Target.FriendlyName}" : Branch.FriendlyName;
+ }
+
+ private void MoveGhost(Point pointer)
+ {
+ if (_ghost == null)
+ return;
+
+ Canvas.SetLeft(_ghost, pointer.X + 14);
+ Canvas.SetTop(_ghost, pointer.Y + 10);
+ }
+
+ private void OnTopLevelDragOver(object sender, DragEventArgs e)
+ {
+ if (!e.DataTransfer.Contains(Format))
+ return;
+
+ if (_ghost != null && Overlay != null)
+ {
+ _ghost.IsVisible = true;
+ MoveGhost(e.GetPosition(Overlay));
+ }
+
+ if (!e.Handled)
+ {
+ // Not above a branch of the same repository.
+ SetDropTarget(null, null, null);
+ e.DragEffects = DragDropEffects.None;
+ e.Handled = true;
+ }
+ }
+
+ private void OnTopLevelDragLeave(object sender, DragEventArgs e)
+ {
+ if (_ghost != null)
+ _ghost.IsVisible = false;
+ }
+
+ private void OnTopLevelDrop(object sender, DragEventArgs e)
+ {
+ if (e.DataTransfer.Contains(Format))
+ e.Handled = true;
+ }
+
+ private readonly TopLevel _topLevel;
+ private readonly Action _highlightSource;
+ private bool _topLevelAllowDrop = false;
+ private object _targetKey = null;
+ private Action _highlightTarget = null;
+ private Border _ghost = null;
+ private TextBlock _label = null;
+ }
+
+ private static State s_state = null;
+ }
+}
diff --git a/src/Views/BranchTree.axaml b/src/Views/BranchTree.axaml
index 05a752b448..e8a3812dca 100644
--- a/src/Views/BranchTree.axaml
+++ b/src/Views/BranchTree.axaml
@@ -16,7 +16,11 @@
SelectionChanged="OnNodesSelectionChanged"
KeyDown="OnTreeKeyDown"
Padding="0,0,2,0"
- ContextRequested="OnTreeContextRequested">
+ ContextRequested="OnTreeContextRequested"
+ DragDrop.AllowDrop="True"
+ DragDrop.DragOver="OnTreeDragOver"
+ DragDrop.DragLeave="OnTreeDragLeave"
+ DragDrop.Drop="OnTreeDrop">
@@ -31,12 +35,19 @@
+
+
@@ -129,63 +140,77 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
diff --git a/src/Views/BranchTree.axaml.cs b/src/Views/BranchTree.axaml.cs
index c966deebd3..eff9f4c1cc 100644
--- a/src/Views/BranchTree.axaml.cs
+++ b/src/Views/BranchTree.axaml.cs
@@ -448,9 +448,69 @@ private void OnNodePointerPressed(object sender, PointerPressedEventArgs e)
if (node.Backend is not Models.Branch branch)
return;
+ _pressedBranchNodeEvent = e;
repo.NavigateToCommit(branch.Head);
}
+ private async void OnNodePointerMoved(object sender, PointerEventArgs e)
+ {
+ if (_pressedBranchNodeEvent == null || BranchDragDrop.Source != null)
+ return;
+
+ if (sender is not Border { DataContext: ViewModels.BranchTreeNode { Backend: Models.Branch branch } node } border)
+ return;
+
+ if (!e.GetCurrentPoint(border).Properties.IsLeftButtonPressed)
+ {
+ _pressedBranchNodeEvent = null;
+ return;
+ }
+
+ if (DataContext is not ViewModels.Repository { Settings: not null } repo)
+ return;
+
+ var delta = e.GetPosition(border) - _pressedBranchNodeEvent.GetPosition(border);
+ if (delta.X * delta.X + delta.Y * delta.Y < 64)
+ return;
+
+ var pressed = _pressedBranchNodeEvent;
+ _pressedBranchNodeEvent = null;
+ await BranchDragDrop.StartAsync(this, pressed, e.GetPosition(this), repo, branch, v => node.IsDragSource = v);
+ }
+
+ private void OnNodePointerReleased(object sender, PointerReleasedEventArgs e)
+ {
+ _pressedBranchNodeEvent = null;
+ }
+
+ private void OnTreeDragOver(object sender, DragEventArgs e)
+ {
+ var node = FindNodeAt(e);
+ BranchDragDrop.HandleDragOver(e, DataContext as ViewModels.Repository, node?.Backend as Models.Branch, node, v =>
+ {
+ if (node != null)
+ node.IsDropTarget = v;
+ });
+ }
+
+ private void OnTreeDragLeave(object sender, DragEventArgs e)
+ {
+ BranchDragDrop.HandleDragLeave();
+ }
+
+ private void OnTreeDrop(object sender, DragEventArgs e)
+ {
+ if (DataContext is ViewModels.Repository repo)
+ BranchDragDrop.HandleDrop(e, this, repo, FindNodeAt(e)?.Backend as Models.Branch);
+ }
+
+ private ViewModels.BranchTreeNode FindNodeAt(DragEventArgs e)
+ {
+ // Drag events are raised on the ListBox itself, so find the row under the pointer by hit-testing.
+ var hit = BranchesPresenter.InputHitTest(e.GetPosition(BranchesPresenter)) as Visual;
+ return hit?.FindAncestorOfType(true)?.DataContext as ViewModels.BranchTreeNode;
+ }
+
private void OnNodesSelectionChanged(object _, SelectionChangedEventArgs e)
{
if (_disableSelectionChangingEvent)
@@ -1477,5 +1537,6 @@ private void TryToAddCustomActionsToRemoteContextMenu(ViewModels.Repository repo
private List _nodes = null;
private bool _disableSelectionChangingEvent = false;
+ private PointerPressedEventArgs _pressedBranchNodeEvent = null;
}
}
diff --git a/src/Views/CommitRefsPresenter.cs b/src/Views/CommitRefsPresenter.cs
index 85e33962a1..e2870c9129 100644
--- a/src/Views/CommitRefsPresenter.cs
+++ b/src/Views/CommitRefsPresenter.cs
@@ -170,6 +170,32 @@ public Models.Decorator DecoratorAt(Point point)
return null;
}
+ ///
+ /// Returns the decorator whose label contains `point`, or null when the point is between or outside labels.
+ ///
+ public Models.Decorator HitTestDecorator(Point point)
+ {
+ var allowWrap = AllowWrap;
+ var x = 1.5;
+ var y = 0.5;
+
+ foreach (var item in _items)
+ {
+ if (allowWrap && x > 1.5 && x + item.Width > Bounds.Width)
+ {
+ x = 1.5;
+ y += 20.0;
+ }
+
+ if (new Rect(x, y, item.Width, 16).Contains(point))
+ return item.Decorator;
+
+ x += item.Width + 4;
+ }
+
+ return null;
+ }
+
public override void Render(DrawingContext context)
{
if (_items.Count == 0)
@@ -186,6 +212,11 @@ public override void Render(DrawingContext context)
context.FillRectangle(Brushes.Transparent, Bounds);
+ // While a branch is dragged (see `BranchDragDrop`), fade its label and highlight the label under the pointer.
+ var dragSource = BranchDragDrop.Source;
+ var dropTarget = BranchDragDrop.Target;
+ var accent = dropTarget != null ? this.FindResource("Brush.Accent") as IBrush : null;
+
foreach (var item in _items)
{
if (allowWrap && x > 1.5 && x + item.Width > Bounds.Width)
@@ -195,6 +226,7 @@ public override void Render(DrawingContext context)
}
var entireRect = new RoundedRect(new Rect(x, y, item.Width, 16), new CornerRadius(4));
+ using IDisposable fade = BranchDragDrop.IsDecoratorOf(item.Decorator, dragSource) ? context.PushOpacity(.4) : null;
if (item.IsHead)
{
if (useGraphColor)
@@ -248,6 +280,13 @@ public override void Render(DrawingContext context)
using (context.PushTransform(Matrix.CreateTranslation(x + 3, y + 3)))
context.DrawGeometry(fg, null, icon);
+ if (accent != null && BranchDragDrop.IsDecoratorOf(item.Decorator, dropTarget))
+ {
+ using (context.PushOpacity(.3))
+ context.DrawRectangle(accent, null, entireRect);
+ context.DrawRectangle(null, new Pen(accent, 2), entireRect);
+ }
+
x += item.Width + 4;
}
}
@@ -273,6 +312,18 @@ protected override void OnDataContextChanged(EventArgs e)
InvalidateMeasure();
}
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+ BranchDragDrop.StateChanged += InvalidateVisual;
+ }
+
+ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ BranchDragDrop.StateChanged -= InvalidateVisual;
+ base.OnDetachedFromVisualTree(e);
+ }
+
protected override Size MeasureOverride(Size availableSize)
{
_items.Clear();
diff --git a/src/Views/Histories.axaml b/src/Views/Histories.axaml
index a3d24529fd..754e4f8bac 100644
--- a/src/Views/Histories.axaml
+++ b/src/Views/Histories.axaml
@@ -35,7 +35,11 @@
RowHeight="26"
LayoutUpdated="OnCommitListLayoutUpdated"
ContextRequested="OnCommitListContextRequested"
- DoubleTapped="OnCommitListDoubleTapped">
+ DoubleTapped="OnCommitListDoubleTapped"
+ DragDrop.AllowDrop="True"
+ DragDrop.DragOver="OnCommitListDragOver"
+ DragDrop.DragLeave="OnCommitListDragLeave"
+ DragDrop.Drop="OnCommitListDrop">