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)