From 0d0019fbe347b59c9d121a83c15dcdc5d3b7bf55 Mon Sep 17 00:00:00 2001 From: Bert Verhelst Date: Wed, 11 Nov 2026 14:12:04 +0100 Subject: [PATCH 1/3] feature: drag a branch onto another branch to open a context menu with actions between them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dropping branch S onto branch T opens a menu with: compare S with T, merge S into T (when T is current), rebase / interactive rebase S on T (when S is current) and create a PR from S into T (local branches resolve to their upstream) - Works within and across the local and remote branch trees - While dragging, the source row is faded, the row under the pointer is highlighted and a label (`source → target`) follows the pointer - Add `Remote.TryGetCreatePullRequestURL` overload with an explicit target branch (cross-fork PRs supported for GitHub) Co-Authored-By: Claude Opus 5.5 --- src/Models/Remote.cs | 72 ++++++ src/Resources/Locales/en_US.axaml | 2 + src/ViewModels/BranchTreeNode.cs | 14 ++ src/Views/BranchTree.axaml | 139 ++++++----- src/Views/BranchTree.axaml.cs | 367 ++++++++++++++++++++++++++++++ 5 files changed, 537 insertions(+), 57 deletions(-) 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/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..539b4598b9 100644 --- a/src/Views/BranchTree.axaml.cs +++ b/src/Views/BranchTree.axaml.cs @@ -6,11 +6,13 @@ using Avalonia.Collections; 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; using Avalonia.VisualTree; namespace SourceGit.Views @@ -448,9 +450,121 @@ 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 || s_dragging != 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; + + var topLevel = TopLevel.GetTopLevel(this); + if (topLevel == null) + return; + + var dragging = new BranchDragState(repo, branch, node, topLevel); + s_dragging = dragging; + node.IsDragSource = true; + dragging.Begin(this, e.GetPosition(dragging.Overlay)); + + try + { + var data = new DataTransfer(); + data.Add(DataTransferItem.Create(s_dndBranchFormat, branch.FullName)); + await DragDrop.DoDragDropAsync(pressed, data, DragDropEffects.Move); + } + finally + { + dragging.End(); + if (s_dragging == dragging) + s_dragging = null; + } + } + + private void OnNodePointerReleased(object sender, PointerReleasedEventArgs e) + { + _pressedBranchNodeEvent = null; + } + + private void OnTreeDragOver(object sender, DragEventArgs e) + { + if (s_dragging == null || !e.DataTransfer.Contains(s_dndBranchFormat)) + return; + + var target = FindDropTarget(e); + s_dragging.SetDropTarget(target); + e.DragEffects = target != null ? DragDropEffects.Move : DragDropEffects.None; + e.Handled = true; + } + + private void OnTreeDragLeave(object sender, DragEventArgs e) + { + s_dragging?.SetDropTarget(null); + } + + private void OnTreeDrop(object sender, DragEventArgs e) + { + if (s_dragging == null || e.DataTransfer.TryGetValue(s_dndBranchFormat) is not { Length: > 0 } sourceFullName) + return; + + e.Handled = true; + + var target = FindDropTarget(e)?.Backend as Models.Branch; + s_dragging.SetDropTarget(null); + + if (target == null || DataContext is not ViewModels.Repository repo) + return; + + var source = repo.Branches.Find(x => x.FullName.Equals(sourceFullName, StringComparison.Ordinal)); + if (source == null) + return; + + var point = e.GetPosition(this); + Dispatcher.UIThread.Post(() => + { + var menu = CreateContextMenuForBranchPair(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(this); + }); + } + + private ViewModels.BranchTreeNode FindDropTarget(DragEventArgs e) + { + if (s_dragging == null || DataContext != s_dragging.Repo) + return null; + + // 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; + var item = hit?.FindAncestorOfType(true); + if (item?.DataContext is not ViewModels.BranchTreeNode { Backend: Models.Branch branch } node) + return null; + + return branch.FullName.Equals(s_dragging.Branch.FullName, StringComparison.Ordinal) ? null : node; + } + private void OnNodesSelectionChanged(object _, SelectionChangedEventArgs e) { if (_disableSelectionChangingEvent) @@ -1417,6 +1531,103 @@ public ContextMenu CreateContextMenuForRemoteBranch(ViewModels.Repository repo, return menu; } + private ContextMenu CreateContextMenuForBranchPair(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 = this.CreateMenuIcon("Icons.Compare"); + compare.Click += (_, e) => + { + this.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 = this.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 = this.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 = this.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 this.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 = this.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 void TryToAddCustomActionsToBranchContextMenu(ViewModels.Repository repo, ContextMenu menu, Models.Branch branch) { var actions = repo.GetCustomActions(Models.CustomActionScope.Branch); @@ -1477,5 +1688,161 @@ private void TryToAddCustomActionsToRemoteContextMenu(ViewModels.Repository repo private List _nodes = null; private bool _disableSelectionChangingEvent = false; + private PointerPressedEventArgs _pressedBranchNodeEvent = null; + + private static readonly DataFormat s_dndBranchFormat = DataFormat.CreateStringApplicationFormat("sourcegit-dnd-branch"); + private static BranchDragState s_dragging = null; + + /// + /// Visual state of an ongoing branch drag: fades the dragged row, highlights the row under the pointer and + /// shows a label that follows the pointer across the whole window. + /// + private class BranchDragState + { + public ViewModels.Repository Repo { get; } + public Models.Branch Branch { get; } + public OverlayLayer Overlay { get; } + + public BranchDragState(ViewModels.Repository repo, Models.Branch branch, ViewModels.BranchTreeNode node, TopLevel topLevel) + { + Repo = repo; + Branch = branch; + Overlay = OverlayLayer.GetOverlayLayer(topLevel); + _node = node; + _topLevel = topLevel; + } + + public void Begin(Control owner, Point pointer) + { + _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); + _node.IsDragSource = false; + } + + public void SetDropTarget(ViewModels.BranchTreeNode target) + { + if (_target == target) + return; + + if (_target != null) + _target.IsDropTarget = false; + + _target = target; + + if (_target != null) + _target.IsDropTarget = true; + + UpdateLabel(); + } + + private void UpdateLabel() + { + if (_label == null) + return; + + _label.Text = _target?.Backend is Models.Branch target ? $"{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(s_dndBranchFormat)) + 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); + 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(s_dndBranchFormat)) + e.Handled = true; + } + + private readonly ViewModels.BranchTreeNode _node; + private readonly TopLevel _topLevel; + private bool _topLevelAllowDrop = false; + private ViewModels.BranchTreeNode _target = null; + private Border _ghost = null; + private TextBlock _label = null; + } } } From 11bc3f4c60c41e6f3a8ec2df62178fb63f64d0b5 Mon Sep 17 00:00:00 2001 From: Bert Verhelst Date: Wed, 11 Nov 2026 16:21:39 +0100 Subject: [PATCH 2/3] feature: support dragging branches in the commit graph onto other branches - Branch labels in the commit graph can be dragged onto other branch labels in the graph or onto branches in the branch trees (and vice versa), opening the same context menu with the actions between the two branches - While dragging, the dragged label in the graph is faded and the label under the pointer is highlighted - Move the drag state, pointer label and branch-pair context menu out of `BranchTree` into a shared `BranchDragDrop` helper - Add `CommitRefsPresenter.HitTestDecorator` for precise label hit-testing Co-Authored-By: Claude Opus 5.5 --- src/Views/BranchDragDrop.cs | 414 +++++++++++++++++++++++++++++++ src/Views/BranchTree.axaml.cs | 332 +------------------------ src/Views/CommitRefsPresenter.cs | 51 ++++ src/Views/Histories.axaml | 6 +- src/Views/Histories.axaml.cs | 92 +++++++ 5 files changed, 575 insertions(+), 320 deletions(-) create mode 100644 src/Views/BranchDragDrop.cs 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.cs b/src/Views/BranchTree.axaml.cs index 539b4598b9..eff9f4c1cc 100644 --- a/src/Views/BranchTree.axaml.cs +++ b/src/Views/BranchTree.axaml.cs @@ -6,13 +6,11 @@ using Avalonia.Collections; 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; using Avalonia.VisualTree; namespace SourceGit.Views @@ -456,7 +454,7 @@ private void OnNodePointerPressed(object sender, PointerPressedEventArgs e) private async void OnNodePointerMoved(object sender, PointerEventArgs e) { - if (_pressedBranchNodeEvent == null || s_dragging != null) + if (_pressedBranchNodeEvent == null || BranchDragDrop.Source != null) return; if (sender is not Border { DataContext: ViewModels.BranchTreeNode { Backend: Models.Branch branch } node } border) @@ -477,28 +475,7 @@ private async void OnNodePointerMoved(object sender, PointerEventArgs e) var pressed = _pressedBranchNodeEvent; _pressedBranchNodeEvent = null; - - var topLevel = TopLevel.GetTopLevel(this); - if (topLevel == null) - return; - - var dragging = new BranchDragState(repo, branch, node, topLevel); - s_dragging = dragging; - node.IsDragSource = true; - dragging.Begin(this, e.GetPosition(dragging.Overlay)); - - try - { - var data = new DataTransfer(); - data.Add(DataTransferItem.Create(s_dndBranchFormat, branch.FullName)); - await DragDrop.DoDragDropAsync(pressed, data, DragDropEffects.Move); - } - finally - { - dragging.End(); - if (s_dragging == dragging) - s_dragging = null; - } + await BranchDragDrop.StartAsync(this, pressed, e.GetPosition(this), repo, branch, v => node.IsDragSource = v); } private void OnNodePointerReleased(object sender, PointerReleasedEventArgs e) @@ -508,61 +485,30 @@ private void OnNodePointerReleased(object sender, PointerReleasedEventArgs e) private void OnTreeDragOver(object sender, DragEventArgs e) { - if (s_dragging == null || !e.DataTransfer.Contains(s_dndBranchFormat)) - return; - - var target = FindDropTarget(e); - s_dragging.SetDropTarget(target); - e.DragEffects = target != null ? DragDropEffects.Move : DragDropEffects.None; - e.Handled = true; + 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) { - s_dragging?.SetDropTarget(null); + BranchDragDrop.HandleDragLeave(); } private void OnTreeDrop(object sender, DragEventArgs e) { - if (s_dragging == null || e.DataTransfer.TryGetValue(s_dndBranchFormat) is not { Length: > 0 } sourceFullName) - return; - - e.Handled = true; - - var target = FindDropTarget(e)?.Backend as Models.Branch; - s_dragging.SetDropTarget(null); - - if (target == null || DataContext is not ViewModels.Repository repo) - return; - - var source = repo.Branches.Find(x => x.FullName.Equals(sourceFullName, StringComparison.Ordinal)); - if (source == null) - return; - - var point = e.GetPosition(this); - Dispatcher.UIThread.Post(() => - { - var menu = CreateContextMenuForBranchPair(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(this); - }); + if (DataContext is ViewModels.Repository repo) + BranchDragDrop.HandleDrop(e, this, repo, FindNodeAt(e)?.Backend as Models.Branch); } - private ViewModels.BranchTreeNode FindDropTarget(DragEventArgs e) + private ViewModels.BranchTreeNode FindNodeAt(DragEventArgs e) { - if (s_dragging == null || DataContext != s_dragging.Repo) - return null; - // 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; - var item = hit?.FindAncestorOfType(true); - if (item?.DataContext is not ViewModels.BranchTreeNode { Backend: Models.Branch branch } node) - return null; - - return branch.FullName.Equals(s_dragging.Branch.FullName, StringComparison.Ordinal) ? null : node; + return hit?.FindAncestorOfType(true)?.DataContext as ViewModels.BranchTreeNode; } private void OnNodesSelectionChanged(object _, SelectionChangedEventArgs e) @@ -1531,103 +1477,6 @@ public ContextMenu CreateContextMenuForRemoteBranch(ViewModels.Repository repo, return menu; } - private ContextMenu CreateContextMenuForBranchPair(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 = this.CreateMenuIcon("Icons.Compare"); - compare.Click += (_, e) => - { - this.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 = this.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 = this.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 = this.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 this.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 = this.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 void TryToAddCustomActionsToBranchContextMenu(ViewModels.Repository repo, ContextMenu menu, Models.Branch branch) { var actions = repo.GetCustomActions(Models.CustomActionScope.Branch); @@ -1689,160 +1538,5 @@ private void TryToAddCustomActionsToRemoteContextMenu(ViewModels.Repository repo private List _nodes = null; private bool _disableSelectionChangingEvent = false; private PointerPressedEventArgs _pressedBranchNodeEvent = null; - - private static readonly DataFormat s_dndBranchFormat = DataFormat.CreateStringApplicationFormat("sourcegit-dnd-branch"); - private static BranchDragState s_dragging = null; - - /// - /// Visual state of an ongoing branch drag: fades the dragged row, highlights the row under the pointer and - /// shows a label that follows the pointer across the whole window. - /// - private class BranchDragState - { - public ViewModels.Repository Repo { get; } - public Models.Branch Branch { get; } - public OverlayLayer Overlay { get; } - - public BranchDragState(ViewModels.Repository repo, Models.Branch branch, ViewModels.BranchTreeNode node, TopLevel topLevel) - { - Repo = repo; - Branch = branch; - Overlay = OverlayLayer.GetOverlayLayer(topLevel); - _node = node; - _topLevel = topLevel; - } - - public void Begin(Control owner, Point pointer) - { - _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); - _node.IsDragSource = false; - } - - public void SetDropTarget(ViewModels.BranchTreeNode target) - { - if (_target == target) - return; - - if (_target != null) - _target.IsDropTarget = false; - - _target = target; - - if (_target != null) - _target.IsDropTarget = true; - - UpdateLabel(); - } - - private void UpdateLabel() - { - if (_label == null) - return; - - _label.Text = _target?.Backend is Models.Branch target ? $"{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(s_dndBranchFormat)) - 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); - 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(s_dndBranchFormat)) - e.Handled = true; - } - - private readonly ViewModels.BranchTreeNode _node; - private readonly TopLevel _topLevel; - private bool _topLevelAllowDrop = false; - private ViewModels.BranchTreeNode _target = null; - private Border _ghost = null; - private TextBlock _label = 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">