diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml
index 1b1f29d8f..50e9ffd9b 100644
--- a/src/Resources/Locales/en_US.axaml
+++ b/src/Resources/Locales/en_US.axaml
@@ -58,7 +58,9 @@
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
Compare with...
Compare with HEAD
@@ -71,6 +73,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}$
@@ -629,6 +632,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/BranchTreeNode.cs b/src/ViewModels/BranchTreeNode.cs
index 28a49995c..3f6eb423c 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/ViewModels/MoveBranches.cs b/src/ViewModels/MoveBranches.cs
new file mode 100644
index 000000000..ac830f297
--- /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 b/src/Views/BranchTree.axaml
index 05a752b44..0b9343e5b 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 c966deebd..abb39eddd 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;
@@ -11,6 +12,7 @@
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
+using Avalonia.Threading;
using Avalonia.VisualTree;
namespace SourceGit.Views
@@ -431,6 +433,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;
@@ -445,10 +450,384 @@ 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)
+ if (node.Backend is Models.Branch branch)
+ {
+ if (!branch.IsDetachedHead)
+ BeginPress(e, node);
+
+ 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);
+ }
+
+ 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 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;
+ _pressedDragNode = null;
+ _pressedDragPaths = null;
+ return;
+ }
+
+ var delta = e.GetPosition(border) - _pressedNodeEvent.GetPosition(border);
+ if (delta.X * delta.X + delta.Y * delta.Y < 64)
+ return;
+
+ _startDragNode = true;
+
+ var pressedEvent = _pressedNodeEvent;
+ var pressedNode = _pressedDragNode;
+ var paths = _pressedDragPaths;
+
+ var data = new DataTransfer();
+ data.Add(DataTransferItem.Create(DndBranchFormat, string.Join('\n', paths)));
+
+ 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 = pressedEvent.GetPosition(layer);
+ _dragGhost = CreateDragGhost(pressedNode, paths.Count);
+ _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);
+
+ _lastDragPosition = pressedEvent.GetPosition(BranchesPresenter);
+ _autoScrollTimer = new DispatcherTimer() { Interval = TimeSpan.FromMilliseconds(50) };
+ _autoScrollTimer.Tick += OnAutoScrollTick;
+ _autoScrollTimer.Start();
+ }
+
+ try
+ {
+ 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)
+ DragDrop.SetAllowDrop(topLevel, allowDrop);
+ }
+
+ _pressedNodeEvent = null;
+ _pressedDragNode = null;
+ _pressedDragPaths = 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);
+
+ _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 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(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 = 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,
+ 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 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 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)
+ {
+ 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)
+ {
+ if (sender is not Border { DataContext: ViewModels.BranchTreeNode node })
+ return;
+
+ node.IsDropTarget = false;
+ CancelAutoExpand();
+
+ var paths = GetDraggedPaths(e);
+ if (DataContext is not ViewModels.Repository repo || paths.Length == 0)
+ return;
+
+ 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;
- repo.NavigateToCommit(branch.Head);
+ e.Handled = true;
+
+ var menu = await CreateContextMenuForBranchDropAsync(repo, source, target);
+ menu.Open(this);
}
private void OnNodesSelectionChanged(object _, SelectionChangedEventArgs e)
@@ -1117,6 +1496,121 @@ 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();
+
+ // 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 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(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();
@@ -1475,7 +1969,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 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 000000000..5491fe2f6
--- /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 000000000..e628375a0
--- /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 700577347..e79c9af22 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 93d77396e..d9a41d2ad 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)