From 83e898f6b12f4876f5b65ca84029c7c9bb16c7c8 Mon Sep 17 00:00:00 2001 From: dakkafex <7563763+dakkafex@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:20:13 +0100 Subject: [PATCH 1/6] feature: show a menu when dragging a branch onto another branch Branches in the sidebar tree can now be dragged onto each other to get a menu of operations between the two. When one of the branches is the checked-out one the menu offers merge, rebase and interactive rebase. Fast-forwarding a branch that is not checked out is only offered when it is a real fast-forward. Compare is always available. --- src/Resources/Locales/en_US.axaml | 2 + src/ViewModels/BranchTreeNode.cs | 7 ++ src/Views/BranchTree.axaml | 18 +++- src/Views/BranchTree.axaml.cs | 167 ++++++++++++++++++++++++++++++ 4 files changed, 193 insertions(+), 1 deletion(-) diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index 1b1f29d8fb..18de4cae87 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -59,6 +59,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 @@ -71,6 +72,7 @@ Delete selected {0} branches... Edit description for ${0}$... Fast-Forward to ${0}$ + Fast-Forward ${0}$ to ${1}$ Fetch ${0}$ into ${1}$ Git Flow - Finish ${0}$... Interactively Rebase ${0}$ on ${1}$ diff --git a/src/ViewModels/BranchTreeNode.cs b/src/ViewModels/BranchTreeNode.cs index 28a49995ce..3f6eb423c3 100644 --- a/src/ViewModels/BranchTreeNode.cs +++ b/src/ViewModels/BranchTreeNode.cs @@ -34,6 +34,12 @@ public CornerRadius CornerRadius set => SetProperty(ref _cornerRadius, value); } + public bool IsDropTarget + { + get => _isDropTarget; + set => SetProperty(ref _isDropTarget, value); + } + public bool IsBranch { get => Backend is Models.Branch; @@ -56,6 +62,7 @@ public string BranchesCount private Models.FilterMode _filterMode = Models.FilterMode.None; private bool _isExpanded = false; + private bool _isDropTarget = false; private CornerRadius _cornerRadius = new CornerRadius(4); public class Builder diff --git a/src/Views/BranchTree.axaml b/src/Views/BranchTree.axaml index 05a752b448..0b9343e5bf 100644 --- a/src/Views/BranchTree.axaml +++ b/src/Views/BranchTree.axaml @@ -35,10 +35,26 @@ - + + + + + diff --git a/src/Views/BranchTree.axaml.cs b/src/Views/BranchTree.axaml.cs index c966deebd3..2bdd52e2fc 100644 --- a/src/Views/BranchTree.axaml.cs +++ b/src/Views/BranchTree.axaml.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.Threading.Tasks; using Avalonia; using Avalonia.Collections; @@ -431,6 +432,9 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang private void OnNodePointerPressed(object sender, PointerPressedEventArgs e) { + _pressedNodeEvent = null; + _startDragNode = false; + var ctrl = OperatingSystem.IsMacOS() ? KeyModifiers.Meta : KeyModifiers.Control; if (e.KeyModifiers.HasFlag(ctrl) || e.KeyModifiers.HasFlag(KeyModifiers.Shift)) return; @@ -448,9 +452,81 @@ private void OnNodePointerPressed(object sender, PointerPressedEventArgs e) if (node.Backend is not Models.Branch branch) return; + if (!branch.IsDetachedHead) + _pressedNodeEvent = e; + repo.NavigateToCommit(branch.Head); } + private void OnNodePointerReleased(object _1, PointerReleasedEventArgs _2) + { + _pressedNodeEvent = null; + _startDragNode = false; + } + + private async void OnNodePointerMoved(object sender, PointerEventArgs e) + { + if (_pressedNodeEvent == null || + _startDragNode || + sender is not Border { DataContext: ViewModels.BranchTreeNode { Backend: Models.Branch branch } } border) + return; + + var delta = e.GetPosition(border) - _pressedNodeEvent.GetPosition(border); + if (delta.X * delta.X + delta.Y * delta.Y < 64) + return; + + _startDragNode = true; + + var data = new DataTransfer(); + data.Add(DataTransferItem.Create(_dndBranchFormat, branch.FullName)); + await DragDrop.DoDragDropAsync(_pressedNodeEvent, data, DragDropEffects.Move); + + _pressedNodeEvent = null; + _startDragNode = false; + } + + private void OnNodeDragOver(object sender, DragEventArgs e) + { + e.DragEffects = DragDropEffects.None; + e.Handled = true; + + if (sender is Border { DataContext: ViewModels.BranchTreeNode { Backend: Models.Branch { IsDetachedHead: false } target } node } && + e.DataTransfer.TryGetValue(_dndBranchFormat) is { } fullName && + !fullName.Equals(target.FullName, StringComparison.Ordinal)) + { + e.DragEffects = DragDropEffects.Move; + node.IsDropTarget = true; + } + } + + private void OnNodeDragLeave(object sender, DragEventArgs _) + { + if (sender is Border { DataContext: ViewModels.BranchTreeNode node }) + node.IsDropTarget = false; + } + + private async void OnNodeDrop(object sender, DragEventArgs e) + { + if (sender is not Border { DataContext: ViewModels.BranchTreeNode node }) + return; + + node.IsDropTarget = false; + + if (DataContext is not ViewModels.Repository repo || + node.Backend is not Models.Branch { IsDetachedHead: false } target || + e.DataTransfer.TryGetValue(_dndBranchFormat) is not { } fullName) + return; + + var source = repo.Branches.Find(x => x.FullName.Equals(fullName, StringComparison.Ordinal)); + if (source == null || ReferenceEquals(source, target)) + return; + + e.Handled = true; + + var menu = await CreateContextMenuForBranchDropAsync(repo, source, target); + menu.Open(this); + } + private void OnNodesSelectionChanged(object _, SelectionChangedEventArgs e) { if (_disableSelectionChangingEvent) @@ -1117,6 +1193,94 @@ private ContextMenu CreateContextMenuForLocalBranch(ViewModels.Repository repo, return menu; } + private async Task CreateContextMenuForBranchDropAsync(ViewModels.Repository repo, Models.Branch source, Models.Branch target) + { + var current = repo.CurrentBranch; + var menu = new ContextMenu(); + + // Merge and rebase always act on the checked-out branch, so they are only + // offered when exactly one of the two branches is that one. + if (!repo.IsBare && current != null && (source.IsCurrent || target.IsCurrent)) + { + var other = target.IsCurrent ? source : target; + + var merge = new MenuItem(); + merge.Header = App.Text("BranchCM.Merge", other.FriendlyName, current.Name); + merge.Icon = this.CreateMenuIcon("Icons.Merge"); + merge.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.Merge(repo, other, current.Name, false)); + e.Handled = true; + }; + + var rebase = new MenuItem(); + rebase.Header = App.Text("BranchCM.Rebase", current.Name, other.FriendlyName); + rebase.Icon = this.CreateMenuIcon("Icons.Rebase"); + rebase.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.Rebase(repo, current, other)); + e.Handled = true; + }; + + var interactiveRebase = new MenuItem(); + interactiveRebase.Header = App.Text("BranchCM.InteractiveRebase.Manually", current.Name, other.FriendlyName); + interactiveRebase.Icon = this.CreateMenuIcon("Icons.InteractiveRebase"); + interactiveRebase.IsEnabled = !current.Head.Equals(other.Head, StringComparison.Ordinal); + interactiveRebase.Click += async (_, e) => + { + var commit = await new Commands.QuerySingleCommit(repo.FullPath, other.Head).GetResultAsync(); + await this.ShowDialogAsync(new ViewModels.InteractiveRebase(repo, commit)); + e.Handled = true; + }; + + menu.Items.Add(merge); + menu.Items.Add(rebase); + menu.Items.Add(new MenuItem() { Header = "-" }); + menu.Items.Add(interactiveRebase); + } + + // Moving a branch that is not checked out is only offered as a true fast-forward, + // because ResetWithoutCheckout would otherwise silently drop commits. + if (target.IsLocal && + !target.IsCurrent && + string.IsNullOrEmpty(target.WorktreePath) && + !target.Head.Equals(source.Head, StringComparison.Ordinal) && + await new Commands.IsAncestor(repo.FullPath, target.Head, source.Head).GetResultAsync()) + { + var fastForward = new MenuItem(); + fastForward.Header = App.Text("BranchCM.FastForwardBranch", target.Name, source.FriendlyName); + fastForward.Icon = this.CreateMenuIcon("Icons.FastForward"); + fastForward.Click += async (_, e) => + { + if (repo.CanCreatePopup()) + await repo.ShowAndStartPopupAsync(new ViewModels.ResetWithoutCheckout(repo, target, source)); + e.Handled = true; + }; + + if (menu.Items.Count > 0) + menu.Items.Add(new MenuItem() { Header = "-" }); + + menu.Items.Add(fastForward); + } + + 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, source, target)); + e.Handled = true; + }; + + if (menu.Items.Count > 0) + menu.Items.Add(new MenuItem() { Header = "-" }); + + menu.Items.Add(compare); + return menu; + } + private ContextMenu CreateContextMenuForRemote(ViewModels.Repository repo, Models.Remote remote) { var menu = new ContextMenu(); @@ -1477,5 +1641,8 @@ private void TryToAddCustomActionsToRemoteContextMenu(ViewModels.Repository repo private List _nodes = null; private bool _disableSelectionChangingEvent = false; + private PointerPressedEventArgs _pressedNodeEvent = null; + private bool _startDragNode = false; + private readonly DataFormat _dndBranchFormat = DataFormat.CreateStringApplicationFormat("sourcegit-dnd-branch"); } } From 36ab37d19fe6dd4c6fb3ff09c6ff16de1e1c3004 Mon Sep 17 00:00:00 2001 From: dakkafex <7563763+dakkafex@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:40:34 +0100 Subject: [PATCH 2/6] ux: show a ghost chip with the branch name while dragging a branch Avalonia has no drag preview API, so only the OS grabbing cursor was visible. Draw a small chip with the branch icon and name on the overlay layer and move it with the pointer for the duration of the drag. --- src/Views/BranchTree.axaml.cs | 84 +++++++++++++++++++++++++++++++++-- 1 file changed, 81 insertions(+), 3 deletions(-) diff --git a/src/Views/BranchTree.axaml.cs b/src/Views/BranchTree.axaml.cs index 2bdd52e2fc..107ff6d644 100644 --- a/src/Views/BranchTree.axaml.cs +++ b/src/Views/BranchTree.axaml.cs @@ -479,10 +479,86 @@ private async void OnNodePointerMoved(object sender, PointerEventArgs e) var data = new DataTransfer(); data.Add(DataTransferItem.Create(_dndBranchFormat, branch.FullName)); - await DragDrop.DoDragDropAsync(_pressedNodeEvent, data, DragDropEffects.Move); - _pressedNodeEvent = null; - _startDragNode = false; + var topLevel = TopLevel.GetTopLevel(this); + var allowDrop = topLevel != null && DragDrop.GetAllowDrop(topLevel); + if (topLevel != null && OverlayLayer.GetOverlayLayer(this) is { } layer) + { + // Avalonia has no drag preview, so a chip is drawn on the overlay and moved on every DragOver + // of the window (which only fires for AllowDrop targets). + var start = _pressedNodeEvent.GetPosition(layer); + _dragGhost = CreateDragGhost(branch); + _dragGhostLayer = layer; + Canvas.SetLeft(_dragGhost, start.X + 14); + Canvas.SetTop(_dragGhost, start.Y + 14); + layer.Children.Add(_dragGhost); + + DragDrop.SetAllowDrop(topLevel, true); + topLevel.AddHandler(DragDrop.DragOverEvent, OnDragGhostDragOver, RoutingStrategies.Tunnel | RoutingStrategies.Bubble, true); + } + + try + { + await DragDrop.DoDragDropAsync(_pressedNodeEvent, data, DragDropEffects.Move); + } + finally + { + if (_dragGhost != null) + { + topLevel?.RemoveHandler(DragDrop.DragOverEvent, OnDragGhostDragOver); + _dragGhostLayer.Children.Remove(_dragGhost); + _dragGhost = null; + _dragGhostLayer = null; + + if (topLevel != null) + DragDrop.SetAllowDrop(topLevel, allowDrop); + } + + _pressedNodeEvent = null; + _startDragNode = false; + } + } + + private void OnDragGhostDragOver(object sender, DragEventArgs e) + { + if (_dragGhost == null || _dragGhostLayer == null) + return; + + var pos = e.GetPosition(_dragGhostLayer); + Canvas.SetLeft(_dragGhost, pos.X + 14); + Canvas.SetTop(_dragGhost, pos.Y + 14); + } + + private Control CreateDragGhost(Models.Branch branch) + { + var foreground = this.FindResource("Brush.FG1") as IBrush; + var content = new StackPanel() { Orientation = Orientation.Horizontal, Spacing = 6 }; + + if (this.CreateMenuIcon("Icons.Branch") is { } icon) + { + icon.Fill = foreground; + content.Children.Add(icon); + } + + content.Children.Add(new TextBlock() + { + Text = branch.FriendlyName, + FontWeight = FontWeight.Bold, + Foreground = foreground, + VerticalAlignment = VerticalAlignment.Center, + }); + + return new Border() + { + Child = content, + Padding = new Thickness(8, 4), + CornerRadius = new CornerRadius(4), + BorderThickness = new Thickness(1), + BorderBrush = this.FindResource("Brush.PopupBorder") as IBrush, + Background = this.FindResource("Brush.Popup") as IBrush, + Opacity = 0.92, + IsHitTestVisible = false, + }; } private void OnNodeDragOver(object sender, DragEventArgs e) @@ -1643,6 +1719,8 @@ private void TryToAddCustomActionsToRemoteContextMenu(ViewModels.Repository repo private bool _disableSelectionChangingEvent = false; private PointerPressedEventArgs _pressedNodeEvent = null; private bool _startDragNode = false; + private Control _dragGhost = null; + private OverlayLayer _dragGhostLayer = null; private readonly DataFormat _dndBranchFormat = DataFormat.CreateStringApplicationFormat("sourcegit-dnd-branch"); } } From e526c1790776d3ef6027323cb5cad53bcbb40c91 Mon Sep 17 00:00:00 2001 From: dakkafex Date: Tue, 27 Oct 2026 12:39:56 +0100 Subject: [PATCH 3/6] fix: do not start a branch drag after a missed pointer release Double-clicking a branch to check it out can rebuild the tree, so the pressed row never receives PointerReleased. The stale press then started a drag (and showed the ghost chip) on the next mouse move with no button held. Require the left button to still be pressed before starting a drag. --- src/Views/BranchTree.axaml.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Views/BranchTree.axaml.cs b/src/Views/BranchTree.axaml.cs index 107ff6d644..ff3c01889d 100644 --- a/src/Views/BranchTree.axaml.cs +++ b/src/Views/BranchTree.axaml.cs @@ -471,6 +471,14 @@ private async void OnNodePointerMoved(object sender, PointerEventArgs e) sender is not Border { DataContext: ViewModels.BranchTreeNode { Backend: Models.Branch branch } } border) return; + // The release can be missed (e.g. the tree is rebuilt by a double-click checkout), + // so a stale press must not turn into a drag once the button is up. + if (!e.GetCurrentPoint(border).Properties.IsLeftButtonPressed) + { + _pressedNodeEvent = null; + return; + } + var delta = e.GetPosition(border) - _pressedNodeEvent.GetPosition(border); if (delta.X * delta.X + delta.Y * delta.Y < 64) return; From 2384fe977dbee4a4dd969ca01031c294489d302c Mon Sep 17 00:00:00 2001 From: dakkafex Date: Tue, 27 Oct 2026 13:12:26 +0100 Subject: [PATCH 4/6] feature: move branches into a folder by dragging them onto it Dropping a local branch, a folder or a multi-selection onto a folder in the local branch tree (or onto the "Local Branches" header for the top level) opens a popup that lists the resulting renames and applies them. The names are validated first because `git branch -M` would silently overwrite an existing branch. While dragging, a collapsed folder expands after hovering over it, the list scrolls near its edges, and the ghost chip shows what a drop would do and dims over invalid targets. --- src/Resources/Locales/en_US.axaml | 5 + src/ViewModels/MoveBranches.cs | 173 +++++++++++++++++++ src/Views/BranchTree.axaml.cs | 275 +++++++++++++++++++++++++++--- src/Views/MoveBranches.axaml | 51 ++++++ src/Views/MoveBranches.axaml.cs | 12 ++ src/Views/Repository.axaml | 3 +- src/Views/Repository.axaml.cs | 21 +++ 7 files changed, 515 insertions(+), 25 deletions(-) create mode 100644 src/ViewModels/MoveBranches.cs create mode 100644 src/Views/MoveBranches.axaml create mode 100644 src/Views/MoveBranches.axaml.cs diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index 18de4cae87..0d239ad38b 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -631,6 +631,11 @@ Commit all changes Strategy: Targets: + Move Branches + Branches: + {0} items + (top level) + Move Into: Move Submodule Move To: Submodule: diff --git a/src/ViewModels/MoveBranches.cs b/src/ViewModels/MoveBranches.cs new file mode 100644 index 0000000000..ac830f2972 --- /dev/null +++ b/src/ViewModels/MoveBranches.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels +{ + public class MoveBranches : Popup + { + public const string LocalPrefix = "refs/heads/"; + + public string Target + { + get; + } + + public List Preview + { + get; + } = []; + + public string ErrorMessage + { + get; + } = string.Empty; + + public bool HasError + { + get => !string.IsNullOrEmpty(ErrorMessage); + } + + public MoveBranches(Repository repo, IReadOnlyList paths, string targetPrefix) + { + _repo = repo; + Target = string.IsNullOrEmpty(targetPrefix) ? App.Text("MoveBranches.Root") : $"{targetPrefix}/"; + + foreach (var path in paths) + { + if (!path.StartsWith(LocalPrefix, StringComparison.Ordinal)) + continue; + + var parent = GetParent(path.Substring(LocalPrefix.Length)); + if (parent.Equals(targetPrefix, StringComparison.Ordinal)) + continue; + + var folderPrefix = $"{path}/"; + foreach (var branch in repo.Branches) + { + if (!branch.IsLocal) + continue; + + if (branch.FullName.Equals(path, StringComparison.Ordinal) || + branch.FullName.StartsWith(folderPrefix, StringComparison.Ordinal)) + { + var leaf = parent.Length == 0 ? branch.Name : branch.Name.Substring(parent.Length + 1); + var newName = targetPrefix.Length == 0 ? leaf : $"{targetPrefix}/{leaf}"; + _moves.Add(new KeyValuePair(branch, newName)); + Preview.Add($"{branch.Name} → {newName}"); + } + } + } + + ErrorMessage = Validate(); + } + + // Whether dropping the given tree node paths on the folder `targetPrefix` ("" for the top level) would + // move anything. Folders cannot be dropped into themselves, and items already there are no-ops. + public static bool CanDropOnto(IReadOnlyList paths, string targetPrefix) + { + if (paths.Count == 0) + return false; + + var anyMoves = false; + foreach (var path in paths) + { + if (!path.StartsWith(LocalPrefix, StringComparison.Ordinal)) + return false; + + var relative = path.Substring(LocalPrefix.Length); + if (targetPrefix.Equals(relative, StringComparison.Ordinal) || + targetPrefix.StartsWith($"{relative}/", StringComparison.Ordinal)) + return false; + + if (!GetParent(relative).Equals(targetPrefix, StringComparison.Ordinal)) + anyMoves = true; + } + + return anyMoves; + } + + public override async Task Sure() + { + if (HasError) + { + _repo.SendNotification(ErrorMessage, true); + return false; + } + + using var lockWatcher = _repo.LockWatcher(); + + var log = _repo.CreateLog("Move Branches"); + Use(log); + + var succ = true; + foreach (var (branch, newName) in _moves) + { + ProgressDescription = $"Rename '{branch.Name}'"; + + succ = await new Commands.Branch(_repo.FullPath, branch.Name) + .Use(log) + .RenameAsync(newName); + + if (!succ) + break; + + _repo.RefreshAfterRenameBranch(branch, newName); + } + + log.Complete(); + return succ; + } + + private string Validate() + { + if (_moves.Count == 0) + return "Nothing to move!"; + + var moving = new HashSet(); + foreach (var (branch, _) in _moves) + moving.Add(branch); + + for (var i = 0; i < _moves.Count; i++) + { + var name = _moves[i].Value; + if (!Models.RefName.IsValidBranchName(name)) + return $"Bad branch name format: {name}"; + + foreach (var other in _repo.Branches) + { + if (!other.IsLocal || moving.Contains(other)) + continue; + + if (Conflicts(name, other.Name)) + return $"A branch named '{other.Name}' already exists!!!"; + } + + for (var j = i + 1; j < _moves.Count; j++) + { + if (Conflicts(name, _moves[j].Value)) + return $"Branches would end up with the same name: {name}"; + } + } + + return string.Empty; + } + + // Two branch names cannot coexist when they are equal or one is a "directory" of the other. + private static bool Conflicts(string a, string b) + { + return a.Equals(b, StringComparison.Ordinal) || + a.StartsWith($"{b}/", StringComparison.Ordinal) || + b.StartsWith($"{a}/", StringComparison.Ordinal); + } + + private static string GetParent(string relativePath) + { + var idx = relativePath.LastIndexOf('/'); + return idx < 0 ? string.Empty : relativePath.Substring(0, idx); + } + + private readonly Repository _repo; + private readonly List> _moves = []; + } +} diff --git a/src/Views/BranchTree.axaml.cs b/src/Views/BranchTree.axaml.cs index ff3c01889d..01fd3e3743 100644 --- a/src/Views/BranchTree.axaml.cs +++ b/src/Views/BranchTree.axaml.cs @@ -12,6 +12,7 @@ using Avalonia.Interactivity; using Avalonia.Layout; using Avalonia.Media; +using Avalonia.Threading; using Avalonia.VisualTree; namespace SourceGit.Views @@ -449,26 +450,75 @@ private void OnNodePointerPressed(object sender, PointerPressedEventArgs e) if (sender is not Border { DataContext: ViewModels.BranchTreeNode node }) return; - if (node.Backend is not Models.Branch branch) - return; + if (node.Backend is Models.Branch branch) + { + if (!branch.IsDetachedHead) + BeginPress(e, node); - if (!branch.IsDetachedHead) - _pressedNodeEvent = e; + repo.NavigateToCommit(branch.Head); + } + else if (node.Backend == null && node.Path.StartsWith(ViewModels.MoveBranches.LocalPrefix, StringComparison.Ordinal)) + { + // Local folders can be dragged too (to move all of their branches). + BeginPress(e, node); + } + } + + private void BeginPress(PointerPressedEventArgs e, ViewModels.BranchTreeNode node) + { + _pressedNodeEvent = e; + _pressedDragNode = node; + _pressedDragPaths = CollectDragPaths(node); + } + + // Dragging a row that is part of a multi-selection drags the whole selection; nested rows + // (children of a selected folder) are dropped because the folder already covers them. + private List CollectDragPaths(ViewModels.BranchTreeNode pressed) + { + var paths = new List(); + if (pressed.IsSelected && BranchesPresenter.SelectedItems is { Count: > 1 } selected) + { + var local = pressed.Path.StartsWith(ViewModels.MoveBranches.LocalPrefix, StringComparison.Ordinal); + foreach (var item in selected) + { + if (item is ViewModels.BranchTreeNode { Backend: null or Models.Branch { IsDetachedHead: false } } node && + node.Path.StartsWith(ViewModels.MoveBranches.LocalPrefix, StringComparison.Ordinal) == local) + paths.Add(node.Path); + } - repo.NavigateToCommit(branch.Head); + var all = paths.ToArray(); + paths.RemoveAll(p => Array.Exists(all, q => p.StartsWith($"{q}/", StringComparison.Ordinal))); + } + + if (paths.Count == 0) + paths.Add(pressed.Path); + + return paths; + } + + // The paths of the tree nodes carried by a branch drag (see `OnNodePointerMoved`). + public static string[] GetDraggedPaths(DragEventArgs e) + { + return e.DataTransfer.TryGetValue(DndBranchFormat) is { Length: > 0 } text + ? text.Split('\n', StringSplitOptions.RemoveEmptyEntries) + : []; } private void OnNodePointerReleased(object _1, PointerReleasedEventArgs _2) { _pressedNodeEvent = null; + _pressedDragNode = null; + _pressedDragPaths = null; _startDragNode = false; } private async void OnNodePointerMoved(object sender, PointerEventArgs e) { if (_pressedNodeEvent == null || + _pressedDragNode == null || + _pressedDragPaths == null || _startDragNode || - sender is not Border { DataContext: ViewModels.BranchTreeNode { Backend: Models.Branch branch } } border) + sender is not Border border) return; // The release can be missed (e.g. the tree is rebuilt by a double-click checkout), @@ -476,6 +526,8 @@ private async void OnNodePointerMoved(object sender, PointerEventArgs e) if (!e.GetCurrentPoint(border).Properties.IsLeftButtonPressed) { _pressedNodeEvent = null; + _pressedDragNode = null; + _pressedDragPaths = null; return; } @@ -485,8 +537,12 @@ private async void OnNodePointerMoved(object sender, PointerEventArgs e) _startDragNode = true; + var pressedEvent = _pressedNodeEvent; + var pressedNode = _pressedDragNode; + var paths = _pressedDragPaths; + var data = new DataTransfer(); - data.Add(DataTransferItem.Create(_dndBranchFormat, branch.FullName)); + data.Add(DataTransferItem.Create(DndBranchFormat, string.Join('\n', paths))); var topLevel = TopLevel.GetTopLevel(this); var allowDrop = topLevel != null && DragDrop.GetAllowDrop(topLevel); @@ -494,8 +550,8 @@ private async void OnNodePointerMoved(object sender, PointerEventArgs e) { // Avalonia has no drag preview, so a chip is drawn on the overlay and moved on every DragOver // of the window (which only fires for AllowDrop targets). - var start = _pressedNodeEvent.GetPosition(layer); - _dragGhost = CreateDragGhost(branch); + var start = pressedEvent.GetPosition(layer); + _dragGhost = CreateDragGhost(pressedNode, paths.Count); _dragGhostLayer = layer; Canvas.SetLeft(_dragGhost, start.X + 14); Canvas.SetTop(_dragGhost, start.Y + 14); @@ -503,19 +559,34 @@ private async void OnNodePointerMoved(object sender, PointerEventArgs e) DragDrop.SetAllowDrop(topLevel, true); topLevel.AddHandler(DragDrop.DragOverEvent, OnDragGhostDragOver, RoutingStrategies.Tunnel | RoutingStrategies.Bubble, true); + + _lastDragPosition = pressedEvent.GetPosition(BranchesPresenter); + _autoScrollTimer = new DispatcherTimer() { Interval = TimeSpan.FromMilliseconds(50) }; + _autoScrollTimer.Tick += OnAutoScrollTick; + _autoScrollTimer.Start(); } try { - await DragDrop.DoDragDropAsync(_pressedNodeEvent, data, DragDropEffects.Move); + await DragDrop.DoDragDropAsync(pressedEvent, data, DragDropEffects.Move); } finally { + CancelAutoExpand(); + + if (_autoScrollTimer != null) + { + _autoScrollTimer.Stop(); + _autoScrollTimer.Tick -= OnAutoScrollTick; + _autoScrollTimer = null; + } + if (_dragGhost != null) { topLevel?.RemoveHandler(DragDrop.DragOverEvent, OnDragGhostDragOver); _dragGhostLayer.Children.Remove(_dragGhost); _dragGhost = null; + _dragGhostHint = null; _dragGhostLayer = null; if (topLevel != null) @@ -523,6 +594,8 @@ private async void OnNodePointerMoved(object sender, PointerEventArgs e) } _pressedNodeEvent = null; + _pressedDragNode = null; + _pressedDragPaths = null; _startDragNode = false; } } @@ -535,27 +608,113 @@ private void OnDragGhostDragOver(object sender, DragEventArgs e) var pos = e.GetPosition(_dragGhostLayer); Canvas.SetLeft(_dragGhost, pos.X + 14); Canvas.SetTop(_dragGhost, pos.Y + 14); + + _lastDragPosition = e.GetPosition(BranchesPresenter); + + // Reset before the target under the pointer (bubble phase) reports what a drop would do. + if (e.Route == RoutingStrategies.Tunnel) + SetDragHint(string.Empty, true); } - private Control CreateDragGhost(Models.Branch branch) + private void SetDragHint(string hint, bool valid) + { + if (_dragGhost == null || _dragGhostHint == null) + return; + + _dragGhostHint.Text = hint; + _dragGhostHint.IsVisible = hint.Length > 0; + _dragGhost.Opacity = valid ? 0.92 : 0.5; + } + + private void OnAutoScrollTick(object sender, EventArgs e) + { + var scroll = BranchesPresenter.FindDescendantOfType(); + if (scroll == null) + return; + + var width = BranchesPresenter.Bounds.Width; + var height = BranchesPresenter.Bounds.Height; + var pos = _lastDragPosition; + if (pos.X < 0 || pos.X > width || pos.Y < 0 || pos.Y > height) + return; + + var zone = Math.Min(24.0, height / 4); + var step = pos.Y < zone ? -12.0 : (pos.Y > height - zone ? 12.0 : 0.0); + if (step == 0.0) + return; + + var max = Math.Max(0.0, scroll.Extent.Height - scroll.Viewport.Height); + scroll.Offset = new Vector(scroll.Offset.X, Math.Clamp(scroll.Offset.Y + step, 0.0, max)); + } + + private void UpdateAutoExpand(ViewModels.BranchTreeNode node, bool wanted) + { + if (!wanted) + { + CancelAutoExpand(); + return; + } + + if (ReferenceEquals(_expandNode, node)) + return; + + CancelAutoExpand(); + + _expandNode = node; + _expandTimer = new DispatcherTimer() { Interval = TimeSpan.FromMilliseconds(700) }; + _expandTimer.Tick += (_, _) => + { + var target = _expandNode; + CancelAutoExpand(); + + if (target is { IsExpanded: false }) + ToggleNodeIsExpanded(target); + }; + _expandTimer.Start(); + } + + private void CancelAutoExpand() + { + _expandTimer?.Stop(); + _expandTimer = null; + _expandNode = null; + } + + private Control CreateDragGhost(ViewModels.BranchTreeNode node, int count) { var foreground = this.FindResource("Brush.FG1") as IBrush; var content = new StackPanel() { Orientation = Orientation.Horizontal, Spacing = 6 }; - if (this.CreateMenuIcon("Icons.Branch") is { } icon) + if (this.CreateMenuIcon(node.Backend is Models.Branch ? "Icons.Branch" : "Icons.Folder") is { } icon) { icon.Fill = foreground; content.Children.Add(icon); } + string text; + if (count > 1) + text = App.Text("MoveBranches.Count", count); + else if (node.Backend is Models.Branch branch) + text = branch.FriendlyName; + else + text = node.Name; + content.Children.Add(new TextBlock() { - Text = branch.FriendlyName, + Text = text, FontWeight = FontWeight.Bold, Foreground = foreground, VerticalAlignment = VerticalAlignment.Center, }); + _dragGhostHint = new TextBlock() + { + Foreground = this.FindResource("Brush.FG2") as IBrush, + VerticalAlignment = VerticalAlignment.Center, + IsVisible = false, + }; + content.Children.Add(_dragGhostHint); + return new Border() { Child = content, @@ -569,24 +728,68 @@ private Control CreateDragGhost(Models.Branch branch) }; } + private static bool IsLocalFolder(ViewModels.BranchTreeNode node) + { + return node.Backend == null && + node.Path.StartsWith(ViewModels.MoveBranches.LocalPrefix, StringComparison.Ordinal); + } + private void OnNodeDragOver(object sender, DragEventArgs e) { e.DragEffects = DragDropEffects.None; e.Handled = true; - if (sender is Border { DataContext: ViewModels.BranchTreeNode { Backend: Models.Branch { IsDetachedHead: false } target } node } && - e.DataTransfer.TryGetValue(_dndBranchFormat) is { } fullName && - !fullName.Equals(target.FullName, StringComparison.Ordinal)) + if (sender is not Border { DataContext: ViewModels.BranchTreeNode node } || + DataContext is not ViewModels.Repository repo) + return; + + var paths = GetDraggedPaths(e); + var accepted = false; + var hint = string.Empty; + var expandable = false; + + if (paths.Length > 0) { - e.DragEffects = DragDropEffects.Move; - node.IsDropTarget = true; + if (node.Backend is Models.Branch { IsDetachedHead: false } target) + { + if (paths.Length == 1 && + !paths[0].Equals(target.FullName, StringComparison.Ordinal) && + repo.Branches.Exists(x => x.FullName.Equals(paths[0], StringComparison.Ordinal))) + { + accepted = true; + hint = $"→ {target.FriendlyName}"; + } + } + else if (IsLocalFolder(node)) + { + var prefix = node.Path.Substring(ViewModels.MoveBranches.LocalPrefix.Length); + expandable = !node.IsExpanded && paths[0].StartsWith(ViewModels.MoveBranches.LocalPrefix, StringComparison.Ordinal); + + if (ViewModels.MoveBranches.CanDropOnto(paths, prefix)) + { + accepted = true; + hint = $"→ {prefix}/"; + } + } } + + node.IsDropTarget = accepted; + if (accepted) + e.DragEffects = DragDropEffects.Move; + + SetDragHint(hint, accepted); + UpdateAutoExpand(node, expandable); } private void OnNodeDragLeave(object sender, DragEventArgs _) { if (sender is Border { DataContext: ViewModels.BranchTreeNode node }) + { node.IsDropTarget = false; + + if (ReferenceEquals(_expandNode, node)) + CancelAutoExpand(); + } } private async void OnNodeDrop(object sender, DragEventArgs e) @@ -595,13 +798,29 @@ private async void OnNodeDrop(object sender, DragEventArgs e) return; node.IsDropTarget = false; + CancelAutoExpand(); - if (DataContext is not ViewModels.Repository repo || - node.Backend is not Models.Branch { IsDetachedHead: false } target || - e.DataTransfer.TryGetValue(_dndBranchFormat) is not { } fullName) + var paths = GetDraggedPaths(e); + if (DataContext is not ViewModels.Repository repo || paths.Length == 0) return; - var source = repo.Branches.Find(x => x.FullName.Equals(fullName, StringComparison.Ordinal)); + if (IsLocalFolder(node)) + { + var prefix = node.Path.Substring(ViewModels.MoveBranches.LocalPrefix.Length); + if (!ViewModels.MoveBranches.CanDropOnto(paths, prefix)) + return; + + e.Handled = true; + + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.MoveBranches(repo, paths, prefix)); + return; + } + + if (paths.Length != 1 || node.Backend is not Models.Branch { IsDetachedHead: false } target) + return; + + var source = repo.Branches.Find(x => x.FullName.Equals(paths[0], StringComparison.Ordinal)); if (source == null || ReferenceEquals(source, target)) return; @@ -1304,7 +1523,7 @@ private async Task CreateContextMenuForBranchDropAsync(ViewModels.R rebase.Click += (_, e) => { if (repo.CanCreatePopup()) - repo.ShowPopup(new ViewModels.Rebase(repo, current, other)); + repo.ShowPopup(new ViewModels.Rebase(repo, other, current)); e.Handled = true; }; @@ -1723,12 +1942,20 @@ private void TryToAddCustomActionsToRemoteContextMenu(ViewModels.Repository repo menu.Items.Add(new MenuItem() { Header = "-" }); } + public static readonly DataFormat DndBranchFormat = DataFormat.CreateStringApplicationFormat("sourcegit-dnd-branch"); + private List _nodes = null; private bool _disableSelectionChangingEvent = false; private PointerPressedEventArgs _pressedNodeEvent = null; + private ViewModels.BranchTreeNode _pressedDragNode = null; + private List _pressedDragPaths = null; private bool _startDragNode = false; private Control _dragGhost = null; + private TextBlock _dragGhostHint = null; private OverlayLayer _dragGhostLayer = null; - private readonly DataFormat _dndBranchFormat = DataFormat.CreateStringApplicationFormat("sourcegit-dnd-branch"); + private Point _lastDragPosition = default; + private DispatcherTimer _autoScrollTimer = null; + private DispatcherTimer _expandTimer = null; + private ViewModels.BranchTreeNode _expandNode = null; } } diff --git a/src/Views/MoveBranches.axaml b/src/Views/MoveBranches.axaml new file mode 100644 index 0000000000..5491fe2f68 --- /dev/null +++ b/src/Views/MoveBranches.axaml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/MoveBranches.axaml.cs b/src/Views/MoveBranches.axaml.cs new file mode 100644 index 0000000000..e628375a02 --- /dev/null +++ b/src/Views/MoveBranches.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class MoveBranches : UserControl + { + public MoveBranches() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/Repository.axaml b/src/Views/Repository.axaml index 7005773478..e79c9af229 100644 --- a/src/Views/Repository.axaml +++ b/src/Views/Repository.axaml @@ -295,7 +295,8 @@ - + diff --git a/src/Views/Repository.axaml.cs b/src/Views/Repository.axaml.cs index 93d77396ec..d9a41d2ad5 100644 --- a/src/Views/Repository.axaml.cs +++ b/src/Views/Repository.axaml.cs @@ -364,6 +364,27 @@ private void UpdateLeftSidebarLayout() } } + // Dropping local branches (or folders) on the "Local Branches" header moves them to the top level. + private void OnLocalBranchGroupDragOver(object sender, DragEventArgs e) + { + var paths = BranchTree.GetDraggedPaths(e); + e.DragEffects = ViewModels.MoveBranches.CanDropOnto(paths, string.Empty) ? DragDropEffects.Move : DragDropEffects.None; + e.Handled = true; + } + + private void OnLocalBranchGroupDrop(object sender, DragEventArgs e) + { + var paths = BranchTree.GetDraggedPaths(e); + if (DataContext is not ViewModels.Repository repo || + !ViewModels.MoveBranches.CanDropOnto(paths, string.Empty)) + return; + + e.Handled = true; + + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.MoveBranches(repo, paths, string.Empty)); + } + private void OnOpenSortLocalBranchMenu(object sender, RoutedEventArgs e) { if (sender is Button button && DataContext is ViewModels.Repository repo) From 19ba1c96bed13c8098d792fe63259ea0a26c56db Mon Sep 17 00:00:00 2001 From: dakkafex Date: Tue, 27 Oct 2026 13:26:40 +0100 Subject: [PATCH 5/6] ux: merge the dragged branch into the branch it is dropped on The drop menu always merged into the checked-out branch, so dropping A onto B could offer to merge B into A. Dropping A onto B now offers to merge A into B. When B is not checked out it is checked out first, which is only offered with a clean working copy and when B is not checked out in another worktree. --- src/Resources/Locales/en_US.axaml | 1 + src/Views/BranchTree.axaml.cs | 51 +++++++++++++++++++++++-------- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index 0d239ad38b..50e9ffd9b5 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -58,6 +58,7 @@ Holding Ctrl (on Windows/Linux) or ⌘ (on macOS) when clicking commit SHA will rebuild blame data based on that revision. BLAME ON THIS FILE IS NOT SUPPORTED!!! Checkout ${0}$... + Checkout ${1}$ and merge ${0}$ into it... Checkout ${0}$ as worktree... Compare ${0}$ with ${1}$ Compare selected 2 branches diff --git a/src/Views/BranchTree.axaml.cs b/src/Views/BranchTree.axaml.cs index 01fd3e3743..a6a51c52f7 100644 --- a/src/Views/BranchTree.axaml.cs +++ b/src/Views/BranchTree.axaml.cs @@ -1501,22 +1501,50 @@ private async Task CreateContextMenuForBranchDropAsync(ViewModels.R var current = repo.CurrentBranch; var menu = new ContextMenu(); - // Merge and rebase always act on the checked-out branch, so they are only + // Dropping A onto B merges A into B. Merging acts on the checked-out branch, so when B is not + // checked out it is checked out first, which is only offered when that cannot touch local changes. + if (!repo.IsBare && target.IsLocal) + { + var canCheckoutTarget = !target.IsCurrent && + repo.LocalChangesCount == 0 && + string.IsNullOrEmpty(target.WorktreePath); + + if (target.IsCurrent || canCheckoutTarget) + { + var merge = new MenuItem(); + merge.Header = target.IsCurrent ? + App.Text("BranchCM.Merge", source.FriendlyName, target.Name) : + App.Text("BranchCM.CheckoutAndMerge", source.FriendlyName, target.Name); + merge.Icon = this.CreateMenuIcon("Icons.Merge"); + merge.Click += async (_, e) => + { + e.Handled = true; + + if (!repo.CanCreatePopup()) + return; + + if (!target.IsCurrent) + { + await repo.CheckoutBranchAsync(target); + + // The checkout can be declined or fail; only merge when it really happened. + if (!target.IsCurrent || !repo.CanCreatePopup()) + return; + } + + repo.ShowPopup(new ViewModels.Merge(repo, source, target.Name, false)); + }; + + menu.Items.Add(merge); + } + } + + // Rebase always acts on the checked-out branch, so it is only // offered when exactly one of the two branches is that one. if (!repo.IsBare && current != null && (source.IsCurrent || target.IsCurrent)) { var other = target.IsCurrent ? source : target; - var merge = new MenuItem(); - merge.Header = App.Text("BranchCM.Merge", other.FriendlyName, current.Name); - merge.Icon = this.CreateMenuIcon("Icons.Merge"); - merge.Click += (_, e) => - { - if (repo.CanCreatePopup()) - repo.ShowPopup(new ViewModels.Merge(repo, other, current.Name, false)); - e.Handled = true; - }; - var rebase = new MenuItem(); rebase.Header = App.Text("BranchCM.Rebase", current.Name, other.FriendlyName); rebase.Icon = this.CreateMenuIcon("Icons.Rebase"); @@ -1538,7 +1566,6 @@ private async Task CreateContextMenuForBranchDropAsync(ViewModels.R e.Handled = true; }; - menu.Items.Add(merge); menu.Items.Add(rebase); menu.Items.Add(new MenuItem() { Header = "-" }); menu.Items.Add(interactiveRebase); From 9b9e4b82e9b430f14eb47516a6a187548aef6bae Mon Sep 17 00:00:00 2001 From: dakkafex Date: Tue, 27 Oct 2026 13:29:03 +0100 Subject: [PATCH 6/6] fix: rebase the current branch onto the other one from the drop menu The rebase entry passed the branches to the Rebase popup the wrong way round, so it rebased the other branch onto the current one while the menu text said the opposite. --- src/Views/BranchTree.axaml.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Views/BranchTree.axaml.cs b/src/Views/BranchTree.axaml.cs index a6a51c52f7..abb39eddde 100644 --- a/src/Views/BranchTree.axaml.cs +++ b/src/Views/BranchTree.axaml.cs @@ -1551,7 +1551,7 @@ private async Task CreateContextMenuForBranchDropAsync(ViewModels.R rebase.Click += (_, e) => { if (repo.CanCreatePopup()) - repo.ShowPopup(new ViewModels.Rebase(repo, other, current)); + repo.ShowPopup(new ViewModels.Rebase(repo, current, other)); e.Handled = true; };