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">