diff --git a/src/GitHub.Api/Git/GitBranch.cs b/src/GitHub.Api/Git/GitBranch.cs index 733b845df..3e1c747d8 100644 --- a/src/GitHub.Api/Git/GitBranch.cs +++ b/src/GitHub.Api/Git/GitBranch.cs @@ -2,7 +2,7 @@ namespace GitHub.Unity { - interface ITreeData + public interface ITreeData { string Name { get; } bool IsActive { get; } @@ -17,10 +17,6 @@ public struct GitBranch : ITreeData public string tracking; public bool isActive; - public string Name { get { return name; } } - public string Tracking { get { return tracking; } } - public bool IsActive { get { return isActive; } } - public GitBranch(string name, string tracking, bool active) { Guard.ArgumentNotNullOrWhiteSpace(name, "name"); @@ -30,6 +26,10 @@ public GitBranch(string name, string tracking, bool active) this.isActive = active; } + public string Name => name; + public string Tracking => tracking; + public bool IsActive => isActive; + public override string ToString() { return $"{Name} Tracking? {Tracking} Active? {IsActive}"; diff --git a/src/UnityExtension/Assets/Editor/GitHub.Unity/GitHub.Unity.csproj b/src/UnityExtension/Assets/Editor/GitHub.Unity/GitHub.Unity.csproj index ac26b427b..a0b6dd2f1 100644 --- a/src/UnityExtension/Assets/Editor/GitHub.Unity/GitHub.Unity.csproj +++ b/src/UnityExtension/Assets/Editor/GitHub.Unity/GitHub.Unity.csproj @@ -103,6 +103,7 @@ + @@ -207,6 +208,10 @@ + + + + - + \ No newline at end of file diff --git a/src/UnityExtension/Assets/Editor/GitHub.Unity/IconsAndLogos/globe.png b/src/UnityExtension/Assets/Editor/GitHub.Unity/IconsAndLogos/globe.png new file mode 100644 index 000000000..0b1353981 --- /dev/null +++ b/src/UnityExtension/Assets/Editor/GitHub.Unity/IconsAndLogos/globe.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b65bf8e330ede5edc0ae8d620a01f7003fbfdcd1053667c016a103b8ad49a934 +size 594 diff --git a/src/UnityExtension/Assets/Editor/GitHub.Unity/IconsAndLogos/globe@2x.png b/src/UnityExtension/Assets/Editor/GitHub.Unity/IconsAndLogos/globe@2x.png new file mode 100644 index 000000000..792045838 --- /dev/null +++ b/src/UnityExtension/Assets/Editor/GitHub.Unity/IconsAndLogos/globe@2x.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3051c3d5ba2726bf3b9877a44f41ec7f3a68a79afe40bf7fde0f65db1a542b18 +size 1318 diff --git a/src/UnityExtension/Assets/Editor/GitHub.Unity/Misc/Styles.cs b/src/UnityExtension/Assets/Editor/GitHub.Unity/Misc/Styles.cs index 3fdcdc26b..5858502c4 100644 --- a/src/UnityExtension/Assets/Editor/GitHub.Unity/Misc/Styles.cs +++ b/src/UnityExtension/Assets/Editor/GitHub.Unity/Misc/Styles.cs @@ -27,13 +27,14 @@ class Styles MinCommitTreePadding = 20f, FoldoutWidth = 11f, FoldoutIndentation = -2f, + TreePadding = 12f, TreeIndentation = 12f, TreeRootIndentation = -5f, TreeVerticalSpacing = 3f, CommitIconSize = 16f, CommitIconHorizontalPadding = -5f, BranchListIndentation = 20f, - BranchListSeperation = 15f, + BranchListSeparation = 15f, RemotesTotalHorizontalMargin = 37f, RemotesNameRatio = .2f, RemotesUserRatio = .2f, @@ -829,5 +830,79 @@ public static Texture2D DropdownListIcon return dropdownListIcon; } } + + private static Texture2D globeIcon; + public static Texture2D GlobeIcon + { + get + { + if (globeIcon == null) + { + globeIcon = Utility.GetIcon("globe.png", "globe@2x.png"); + } + return globeIcon; + } + } + + private static GUIStyle foldout; + public static GUIStyle Foldout + { + get + { + if (foldout == null) + { + foldout = new GUIStyle(EditorStyles.foldout); + foldout.name = "CustomFoldout"; + + foldout.focused.textColor = Color.white; + foldout.onFocused.textColor = Color.white; + foldout.focused.background = foldout.active.background; + foldout.onFocused.background = foldout.onActive.background; + } + + return foldout; + } + } + + private static GUIStyle treeNode; + public static GUIStyle TreeNode + { + get + { + if (treeNode == null) + { + treeNode = new GUIStyle(GUI.skin.label); + treeNode.name = "Custom TreeNode"; + + var color = new Color(62f / 255f, 125f / 255f, 231f / 255f); + var texture = Utility.GetTextureFromColor(color); + treeNode.focused.background = texture; + treeNode.onFocused.background = texture; + treeNode.focused.textColor = Color.white; + treeNode.onFocused.textColor = Color.white; + } + + return treeNode; + } + } + + private static GUIStyle treeNodeActive; + public static GUIStyle TreeNodeActive + { + get + { + if (treeNodeActive == null) + { + treeNodeActive = new GUIStyle(TreeNode); + treeNodeActive.name = "Custom TreeNode Active"; + treeNodeActive.fontStyle = FontStyle.Bold; + treeNodeActive.focused.textColor = Color.white; + treeNodeActive.active.textColor = Color.white; + } + + return treeNodeActive; + } + } + } } diff --git a/src/UnityExtension/Assets/Editor/GitHub.Unity/Misc/Utility.cs b/src/UnityExtension/Assets/Editor/GitHub.Unity/Misc/Utility.cs index 5816c47cf..8b0e86bc2 100644 --- a/src/UnityExtension/Assets/Editor/GitHub.Unity/Misc/Utility.cs +++ b/src/UnityExtension/Assets/Editor/GitHub.Unity/Misc/Utility.cs @@ -16,12 +16,38 @@ public static Texture2D GetIcon(string filename, string filename2x = "") filename = filename2x; } + Texture2D texture2D = null; + var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream("GitHub.Unity.IconsAndLogos." + filename); if (stream != null) - return stream.ToTexture2D(); + { + texture2D = stream.ToTexture2D(); + } + else + { + var iconPath = EntryPoint.Environment.ExtensionInstallPath.Combine("IconsAndLogos", filename).ToString(SlashMode.Forward); + texture2D = AssetDatabase.LoadAssetAtPath(iconPath); + } + + if (texture2D != null) + { + texture2D.hideFlags = HideFlags.HideAndDontSave; + } + + return texture2D; + } + + public static Texture2D GetTextureFromColor(Color color) + { + Color[] pix = new Color[1]; + pix[0] = color; + + Texture2D result = new Texture2D(1, 1); + result.hideFlags = HideFlags.HideAndDontSave; + result.SetPixels(pix); + result.Apply(); - var iconPath = EntryPoint.Environment.ExtensionInstallPath.Combine("IconsAndLogos", filename).ToString(SlashMode.Forward); - return AssetDatabase.LoadAssetAtPath(iconPath); + return result; } } diff --git a/src/UnityExtension/Assets/Editor/GitHub.Unity/Services/AuthenticationService.cs b/src/UnityExtension/Assets/Editor/GitHub.Unity/Services/AuthenticationService.cs index c8564cfda..3d81553cf 100644 --- a/src/UnityExtension/Assets/Editor/GitHub.Unity/Services/AuthenticationService.cs +++ b/src/UnityExtension/Assets/Editor/GitHub.Unity/Services/AuthenticationService.cs @@ -1,5 +1,4 @@ using System; -using GitHub.Unity; namespace GitHub.Unity { diff --git a/src/UnityExtension/Assets/Editor/GitHub.Unity/UI/BranchesView.cs b/src/UnityExtension/Assets/Editor/GitHub.Unity/UI/BranchesView.cs index 7a2bd68d3..da903033d 100644 --- a/src/UnityExtension/Assets/Editor/GitHub.Unity/UI/BranchesView.cs +++ b/src/UnityExtension/Assets/Editor/GitHub.Unity/UI/BranchesView.cs @@ -1,10 +1,10 @@ using System; +using System.Collections; using System.Collections.Generic; using System.Linq; using GitHub.Unity.Helpers; using UnityEditor; using UnityEngine; -using Debug = System.Diagnostics.Debug; namespace GitHub.Unity { @@ -32,27 +32,26 @@ class BranchesView : Subview private const string DeleteBranchTitle = "Delete Branch?"; private const string DeleteBranchButton = "Delete"; private const string CancelButtonLabel = "Cancel"; - - private bool showLocalBranches = true; - private bool showRemoteBranches = true; + private const string DeleteBranchContextMenuLabel = "Delete"; + private const string SwitchBranchContextMenuLabel = "Switch"; + private const string CheckoutBranchContextMenuLabel = "Checkout"; [NonSerialized] private int listID = -1; - [NonSerialized] private BranchTreeNode newNodeSelection; [NonSerialized] private BranchesMode targetMode; - [SerializeField] private BranchTreeNode activeBranchNode; - [SerializeField] private BranchTreeNode localRoot; + [SerializeField] private BranchesTree treeLocals; + [SerializeField] private BranchesTree treeRemotes; [SerializeField] private BranchesMode mode = BranchesMode.Default; [SerializeField] private string newBranchName; - [SerializeField] private List remotes = new List(); [SerializeField] private Vector2 scroll; - [SerializeField] private BranchTreeNode selectedNode; + [SerializeField] private bool disableDelete; + [SerializeField] private bool disableCreate; [SerializeField] private CacheUpdateEvent lastLocalAndRemoteBranchListChangedEvent; [NonSerialized] private bool localAndRemoteBranchListHasUpdate; - [SerializeField] private GitBranch[] localBranches; - [SerializeField] private GitBranch[] remoteBranches; + [SerializeField] private List localBranches; + [SerializeField] private List remoteBranches; public override void InitializeView(IView parent) { @@ -60,15 +59,13 @@ public override void InitializeView(IView parent) targetMode = mode; } + public override void OnEnable() { base.OnEnable(); + UpdateTreeIcons(); AttachHandlers(Repository); - - if (Repository != null) - { - Repository.CheckLocalAndRemoteBranchListChangedEvent(lastLocalAndRemoteBranchListChangedEvent); - } + Repository.CheckLocalAndRemoteBranchListChangedEvent(lastLocalAndRemoteBranchListChangedEvent); } public override void OnDisable() @@ -99,12 +96,19 @@ private void MaybeUpdateData() { localAndRemoteBranchListHasUpdate = false; - localBranches = Repository.LocalBranches.ToArray(); - remoteBranches = Repository.RemoteBranches.ToArray(); - + localBranches = Repository.LocalBranches.ToList(); + remoteBranches = Repository.RemoteBranches.ToList(); - BuildTree(localBranches, remoteBranches); + BuildTree(); } + + disableDelete = treeLocals.SelectedNode == null || treeLocals.SelectedNode.IsFolder || treeLocals.SelectedNode.IsActive; + disableCreate = treeLocals.SelectedNode == null || treeLocals.SelectedNode.IsFolder || treeLocals.SelectedNode.Level == 0; + } + + public override void OnGUI() + { + Render(); } private void AttachHandlers(IRepository repository) @@ -114,234 +118,84 @@ private void AttachHandlers(IRepository repository) private void DetachHandlers(IRepository repository) { - repository.LocalAndRemoteBranchListChanged -= RepositoryOnLocalAndRemoteBranchListChanged; } - public override void OnGUI() + private void Render() { - OnEmbeddedGUI(); - } + listID = GUIUtility.GetControlID(FocusType.Keyboard); + GUILayout.BeginHorizontal(); + { + OnButtonBarGUI(); + } + GUILayout.EndHorizontal(); - public void OnEmbeddedGUI() - { + var rect = GUILayoutUtility.GetLastRect(); scroll = GUILayout.BeginScrollView(scroll); { - listID = GUIUtility.GetControlID(FocusType.Keyboard); - - GUILayout.BeginHorizontal(); - { - OnButtonBarGUI(); - } - GUILayout.EndHorizontal(); - - GUILayout.BeginVertical(Styles.CommitFileAreaStyle); - { - // Local branches and "create branch" button - showLocalBranches = EditorGUILayout.Foldout(showLocalBranches, LocalTitle); - if (showLocalBranches) - { - GUILayout.BeginHorizontal(); - { - GUILayout.BeginVertical(); - { - OnTreeNodeChildrenGUI(localRoot); - } - GUILayout.EndVertical(); - } - GUILayout.EndHorizontal(); - } - - // Remotes - showRemoteBranches = EditorGUILayout.Foldout(showRemoteBranches, RemoteTitle); - if (showRemoteBranches) - { - GUILayout.BeginHorizontal(); - { - GUILayout.BeginVertical(); - for (var index = 0; index < remotes.Count; ++index) - { - var remote = remotes[index]; - GUILayout.Label(new GUIContent(remote.Name, Styles.FolderIcon), GUILayout.MaxHeight(EditorGUIUtility.singleLineHeight)); - - // Branches of the remote - GUILayout.BeginHorizontal(); - { - GUILayout.Space(Styles.TreeIndentation); - GUILayout.BeginVertical(); - { - OnTreeNodeChildrenGUI(remote.Root); - } - GUILayout.EndVertical(); - } - GUILayout.EndHorizontal(); - - GUILayout.Space(Styles.BranchListSeperation); - } - - GUILayout.EndVertical(); - } - GUILayout.EndHorizontal(); - } - - GUILayout.FlexibleSpace(); - } - GUILayout.EndVertical(); + OnTreeGUI(new Rect(0f, 0f, Position.width, Position.height - rect.height + Styles.CommitAreaPadding)); } - GUILayout.EndScrollView(); if (Event.current.type == EventType.Repaint) { - // Effectuating selection - if (newNodeSelection != null) - { - selectedNode = newNodeSelection; - newNodeSelection = null; - GUIUtility.keyboardControl = listID; - Redraw(); - } - // Effectuating mode switch if (mode != targetMode) { mode = targetMode; - - if (mode == BranchesMode.Create) - { - selectedNode = activeBranchNode; - } - Redraw(); } } } - private int CompareBranches(GitBranch a, GitBranch b) + private void BuildTree() { - if (a.Name.Equals("master")) + if (treeLocals == null) { - return -1; - } + treeLocals = new BranchesTree(); - if (b.Name.Equals("master")) - { - return 1; - } + treeRemotes = new BranchesTree(); + treeRemotes.IsRemote = true; - return 0; - } + UpdateTreeIcons(); + } - private void BuildTree(IEnumerable local, IEnumerable remote) - { - //Clear the selected node - selectedNode = null; - - // Sort - var localBranches = new List(local); - var remoteBranches = new List(remote); localBranches.Sort(CompareBranches); remoteBranches.Sort(CompareBranches); - // Prepare for tracking - var tracking = new List>(); - var localBranchNodes = new List(); + treeLocals.Load(localBranches.Cast(), LocalTitle); + treeRemotes.Load(remoteBranches.Cast(), RemoteTitle); + Redraw(); + } - // Just build directly on the local root, keep track of active branch - localRoot = new BranchTreeNode("", NodeType.Folder, false); - for (var index = 0; index < localBranches.Count; ++index) + private void UpdateTreeIcons() + { + if (treeLocals != null) { - var branch = localBranches[index]; - var node = new BranchTreeNode(branch.Name, NodeType.LocalBranch, branch.IsActive); - localBranchNodes.Add(node); - - // Keep active node for quick reference - if (branch.IsActive) - { - activeBranchNode = node; - } - - // Add to tracking - if (!string.IsNullOrEmpty(branch.Tracking)) - { - var trackingIndex = !remoteBranches.Any() - ? -1 - : Enumerable.Range(0, remoteBranches.Count).FirstOrDefault(i => remoteBranches[i].Name.Equals(branch.Tracking)); - - if (trackingIndex > -1) - { - tracking.Add(new KeyValuePair(index, trackingIndex)); - } - } - - // Build into tree - BuildTree(localRoot, node); + treeLocals.UpdateIcons(Styles.ActiveBranchIcon, Styles.BranchIcon, Styles.FolderIcon, Styles.GlobeIcon); } - // Maintain list of remotes before building their roots, ignoring active state - remotes.Clear(); - for (var index = 0; index < remoteBranches.Count; ++index) + if (treeRemotes != null) { - var branch = remoteBranches[index]; - - // Remote name is always the first level - var remoteName = branch.Name.Substring(0, branch.Name.IndexOf('/')); - - // Get or create this remote - var remoteIndex = Enumerable.Range(1, remotes.Count + 1) - .FirstOrDefault(i => remotes.Count > i - 1 && remotes[i - 1].Name.Equals(remoteName)) - 1; - if (remoteIndex < 0) - { - remotes.Add(new Remote { Name = remoteName, Root = new BranchTreeNode("", NodeType.Folder, false) }); - remoteIndex = remotes.Count - 1; - } - - // Create the branch - var node = new BranchTreeNode(branch.Name, NodeType.RemoteBranch, false) { - Label = branch.Name.Substring(remoteName.Length + 1) - }; - - // Establish tracking link - for (var trackingIndex = 0; trackingIndex < tracking.Count; ++trackingIndex) - { - var pair = tracking[trackingIndex]; - - if (pair.Value == index) - { - localBranchNodes[pair.Key].Tracking = node; - } - } - - // Build on the root of the remote, just like with locals - BuildTree(remotes[remoteIndex].Root, node); + treeRemotes.UpdateIcons(Styles.ActiveBranchIcon, Styles.BranchIcon, Styles.FolderIcon, Styles.GlobeIcon); } - - Redraw(); } - private void BuildTree(BranchTreeNode parent, BranchTreeNode child) + private void UpdateTreeStyles() { - var firstSplit = child.Label.IndexOf('/'); - - // No nesting needed here, this is just a straight add - if (firstSplit < 0) + if (treeLocals != null && treeLocals.FolderStyle == null) { - parent.Children.Add(child); - return; + treeLocals.FolderStyle = Styles.Foldout; + treeLocals.TreeNodeStyle = Styles.TreeNode; + treeLocals.ActiveTreeNodeStyle = Styles.TreeNodeActive; } - // Get or create the next folder level - var folderName = child.Label.Substring(0, firstSplit); - var folder = parent.Children.FirstOrDefault(f => f.Label.Equals(folderName)); - if (folder == null) + if (treeRemotes != null && treeRemotes.FolderStyle == null) { - folder = new BranchTreeNode("", NodeType.Folder, false) { Label = folderName }; - parent.Children.Add(folder); + treeRemotes.FolderStyle = Styles.Foldout; + treeRemotes.TreeNodeStyle = Styles.TreeNode; + treeRemotes.ActiveTreeNodeStyle = Styles.TreeNodeActive; } - - // Pop the folder name from the front of the child label and add it to the folder - child.Label = child.Label.Substring(folderName.Length + 1); - BuildTree(folder, child); } private void OnButtonBarGUI() @@ -350,27 +204,25 @@ private void OnButtonBarGUI() { // Delete button // If the current branch is selected, then do not enable the Delete button - var disableDelete = selectedNode == null || selectedNode.Type == NodeType.Folder || activeBranchNode == selectedNode; EditorGUI.BeginDisabledGroup(disableDelete); { if (GUILayout.Button(DeleteBranchButton, EditorStyles.miniButton, GUILayout.ExpandWidth(false))) { - var selectedBranchName = selectedNode.Name; - var dialogMessage = string.Format(DeleteBranchMessageFormatString, selectedBranchName); - if (EditorUtility.DisplayDialog(DeleteBranchTitle, dialogMessage, DeleteBranchButton, CancelButtonLabel)) - { - GitClient.DeleteBranch(selectedBranchName, true).Start(); - } + DeleteLocalBranch(treeLocals.SelectedNode.Name); } } EditorGUI.EndDisabledGroup(); // Create button GUILayout.FlexibleSpace(); - if (GUILayout.Button(CreateBranchButton, EditorStyles.miniButton, GUILayout.ExpandWidth(false))) + EditorGUI.BeginDisabledGroup(disableCreate); { - targetMode = BranchesMode.Create; + if (GUILayout.Button(CreateBranchButton, EditorStyles.miniButton, GUILayout.ExpandWidth(false))) + { + targetMode = BranchesMode.Create; + } } + EditorGUI.EndDisabledGroup(); } // Branch name + cancel + create else if (mode == BranchesMode.Create) @@ -379,8 +231,8 @@ private void OnButtonBarGUI() { var createBranch = false; var cancelCreate = false; - var cannotCreate = selectedNode == null || - selectedNode.Type == NodeType.Folder || + var cannotCreate = treeLocals.SelectedNode == null || + treeLocals.SelectedNode.IsFolder || !Validation.IsBranchNameValid(newBranchName); // Create on return/enter or cancel on escape @@ -426,21 +278,22 @@ private void OnButtonBarGUI() // Effectuate create if (createBranch) { - GitClient.CreateBranch(newBranchName, selectedNode.Name) - .FinallyInUI((success, e) => { - if (success) - { - Redraw(); - } - else - { - var errorHeader = "fatal: "; - var errorMessage = e.Message.StartsWith(errorHeader) ? e.Message.Remove(0, errorHeader.Length) : e.Message; - - EditorUtility.DisplayDialog(CreateBranchTitle, - errorMessage, - Localization.Ok); - } + GitClient.CreateBranch(newBranchName, treeLocals.SelectedNode.Name) + .FinallyInUI((success, e) => + { + if (success) + { + Redraw(); + } + else + { + var errorHeader = "fatal: "; + var errorMessage = e.Message.StartsWith(errorHeader) ? e.Message.Remove(0, errorHeader.Length) : e.Message; + + EditorUtility.DisplayDialog(CreateBranchTitle, + errorMessage, + Localization.Ok); + } }) .Start(); } @@ -457,172 +310,186 @@ private void OnButtonBarGUI() } } - private void OnTreeNodeGUI(BranchTreeNode node) + private void OnTreeGUI(Rect rect) + { + UpdateTreeStyles(); + + var initialRect = rect; + var treeHadFocus = treeLocals.SelectedNode != null; + + rect = treeLocals.Render(initialRect, rect, scroll, + node =>{ }, + node => { + if (node.IsFolder) + return; + + if(node.IsActive) + return; + + SwitchBranch(node.Name); + }, + node => { + if (node.IsFolder) + return; + + var menu = CreateContextMenuForLocalBranchNode(node); + menu.ShowAsContext(); + }); + + if (treeHadFocus && treeLocals.SelectedNode == null) + treeRemotes.Focus(); + else if (!treeHadFocus && treeLocals.SelectedNode != null) + treeRemotes.Blur(); + + if (treeLocals.RequiresRepaint) + Redraw(); + + treeHadFocus = treeRemotes.SelectedNode != null; + + rect.y += Styles.TreePadding; + + rect = treeRemotes.Render(initialRect, rect, scroll, + node => { }, + node => { + if (node.IsFolder) + return; + + CheckoutRemoteBranch(node.Name); + }, + node => { + if (node.IsFolder) + return; + + var menu = CreateContextMenuForRemoteBranchNode(node); + menu.ShowAsContext(); + }); + + if (treeHadFocus && treeRemotes.SelectedNode == null) + treeLocals.Focus(); + else if (!treeHadFocus && treeRemotes.SelectedNode != null) + treeLocals.Blur(); + + if (treeRemotes.RequiresRepaint) + Redraw(); + + //Debug.LogFormat("reserving: {0} {1} {2}", rect.y - initialRect.y, rect.y, initialRect.y); + GUILayout.Space(rect.y - initialRect.y); + } + + private GenericMenu CreateContextMenuForLocalBranchNode(TreeNode node) { - // Content, style, and rects + var genericMenu = new GenericMenu(); - Texture2D iconContent; + var deleteGuiContent = new GUIContent(DeleteBranchContextMenuLabel); + var switchGuiContent = new GUIContent(SwitchBranchContextMenuLabel); - if (node.Active == true) + if (node.IsActive) { - iconContent = Styles.ActiveBranchIcon; + genericMenu.AddDisabledItem(deleteGuiContent); + genericMenu.AddDisabledItem(switchGuiContent); } else { - if (node.Children.Count > 0) - { - iconContent = Styles.FolderIcon; - } - else - { - iconContent = Styles.BranchIcon; - } - } + genericMenu.AddItem(deleteGuiContent, false, () => { + DeleteLocalBranch(node.Name); + }); - var content = new GUIContent(node.Label, iconContent); - var style = node.Active ? Styles.BoldLabel : Styles.Label; - var rect = GUILayoutUtility.GetRect(content, style, GUILayout.MaxHeight(EditorGUIUtility.singleLineHeight)); - var clickRect = new Rect(0f, rect.y, Position.width, rect.height); + genericMenu.AddItem(switchGuiContent, false, () => { + SwitchBranch(node.Name); + }); + } - var selected = selectedNode == node; - var keyboardFocus = GUIUtility.keyboardControl == listID; + return genericMenu; + } - // Selection highlight and favorite toggle - if (selected) - { - if (Event.current.type == EventType.Repaint) - { - style.Draw(clickRect, GUIContent.none, false, false, true, keyboardFocus); - } - } + private GenericMenu CreateContextMenuForRemoteBranchNode(TreeNode node) + { + var genericMenu = new GenericMenu(); + + var checkoutGuiContent = new GUIContent(CheckoutBranchContextMenuLabel); + + genericMenu.AddItem(checkoutGuiContent, false, () => { + CheckoutRemoteBranch(node.Name); + }); + + return genericMenu; + } - // The actual icon and label - if (Event.current.type == EventType.Repaint) - { - style.Draw(rect, content, false, false, selected, keyboardFocus); - } + private void CheckoutRemoteBranch(string branch) + { + var indexOfFirstSlash = branch.IndexOf('/'); + var originName = branch.Substring(0, indexOfFirstSlash); + var branchName = branch.Substring(indexOfFirstSlash + 1); - // Children - GUILayout.BeginHorizontal(); + if (Repository.LocalBranches.Any(localBranch => localBranch.Name == branchName)) { - GUILayout.Space(Styles.TreeIndentation); - GUILayout.BeginVertical(); - { - OnTreeNodeChildrenGUI(node); - } - GUILayout.EndVertical(); + EditorUtility.DisplayDialog(WarningCheckoutBranchExistsTitle, + String.Format(WarningCheckoutBranchExistsMessage, branchName), WarningCheckoutBranchExistsOK); } - GUILayout.EndHorizontal(); - - // Click selection of the node as well as branch switch - if (Event.current.type == EventType.MouseDown && clickRect.Contains(Event.current.mousePosition)) + else { - newNodeSelection = node; - Event.current.Use(); + var confirmCheckout = EditorUtility.DisplayDialog(ConfirmCheckoutBranchTitle, + String.Format(ConfirmCheckoutBranchMessage, branch, originName), ConfirmCheckoutBranchOK, + ConfirmCheckoutBranchCancel); - if (Event.current.clickCount > 1 && mode == BranchesMode.Default) + if (confirmCheckout) { - if (node.Type == NodeType.LocalBranch) - { - if (EditorUtility.DisplayDialog(ConfirmSwitchTitle, String.Format(ConfirmSwitchMessage, node.Name), ConfirmSwitchOK, ConfirmSwitchCancel)) + GitClient.CreateBranch(branchName, branch).FinallyInUI((success, e) => { + if (success) { - GitClient.SwitchBranch(node.Name) - .FinallyInUI((success, e) => - { - if (success) - { - Redraw(); - } - else - { - EditorUtility.DisplayDialog(Localization.SwitchBranchTitle, - String.Format(Localization.SwitchBranchFailedDescription, node.Name), - Localization.Ok); - } - }).Start(); - } - } - else if (node.Type == NodeType.RemoteBranch) - { - var indexOfFirstSlash = selectedNode.Name.IndexOf('/'); - var originName = selectedNode.Name.Substring(0, indexOfFirstSlash); - var branchName = selectedNode.Name.Substring(indexOfFirstSlash + 1); - - if (localBranches.Any(localBranch => localBranch.Name == branchName)) - { - EditorUtility.DisplayDialog(WarningCheckoutBranchExistsTitle, - String.Format(WarningCheckoutBranchExistsMessage, branchName), - WarningCheckoutBranchExistsOK); + Redraw(); } else { - var confirmCheckout = EditorUtility.DisplayDialog(ConfirmCheckoutBranchTitle, - String.Format(ConfirmCheckoutBranchMessage, node.Name, originName), - ConfirmCheckoutBranchOK, ConfirmCheckoutBranchCancel); - - if (confirmCheckout) - { - GitClient.CreateBranch(branchName, selectedNode.Name) - .FinallyInUI((success, e) => - { - if (success) - { - Redraw(); - } - else - { - EditorUtility.DisplayDialog(Localization.SwitchBranchTitle, - String.Format(Localization.SwitchBranchFailedDescription, node.Name), - Localization.Ok); - } - }).Start(); - } + EditorUtility.DisplayDialog(Localization.SwitchBranchTitle, + String.Format(Localization.SwitchBranchFailedDescription, branch), Localization.Ok); } - } + }).Start(); } } } - private void OnTreeNodeChildrenGUI(BranchTreeNode node) + private void SwitchBranch(string branch) { - if (node == null || node.Children == null) + if (EditorUtility.DisplayDialog(ConfirmSwitchTitle, String.Format(ConfirmSwitchMessage, branch), ConfirmSwitchOK, + ConfirmSwitchCancel)) { - return; + GitClient.SwitchBranch(branch).FinallyInUI((success, e) => { + if (success) + { + Redraw(); + } + else + { + EditorUtility.DisplayDialog(Localization.SwitchBranchTitle, + String.Format(Localization.SwitchBranchFailedDescription, branch), Localization.Ok); + } + }).Start(); } + } - for (var index = 0; index < node.Children.Count; ++index) + private void DeleteLocalBranch(string branch) + { + var dialogMessage = string.Format(DeleteBranchMessageFormatString, branch); + if (EditorUtility.DisplayDialog(DeleteBranchTitle, dialogMessage, DeleteBranchButton, CancelButtonLabel)) { - // The actual GUI of the child - OnTreeNodeGUI(node.Children[index]); + GitClient.DeleteBranch(branch, true).Start(); + } + } - // Keyboard navigation if this child is the current selection - if (selectedNode == node.Children[index] && GUIUtility.keyboardControl == listID && Event.current.type == EventType.KeyDown) - { - int directionY = Event.current.keyCode == KeyCode.UpArrow ? -1 : Event.current.keyCode == KeyCode.DownArrow ? 1 : 0, - directionX = Event.current.keyCode == KeyCode.LeftArrow ? -1 : Event.current.keyCode == KeyCode.RightArrow ? 1 : 0; + private int CompareBranches(GitBranch a, GitBranch b) + { + if (a.Name.Equals("master")) + { + return -1; + } - if (directionY < 0 && index > 0) - { - newNodeSelection = node.Children[index - 1]; - Event.current.Use(); - } - else if (directionY > 0 && index < node.Children.Count - 1) - { - newNodeSelection = node.Children[index + 1]; - Event.current.Use(); - } - else if (directionX < 0) - { - newNodeSelection = node; - Event.current.Use(); - } - else if (directionX > 0 && node.Children[index].Children.Count > 0) - { - newNodeSelection = node.Children[index].Children[0]; - Event.current.Use(); - } - } + if (b.Name.Equals("master")) + { + return 1; } + + return a.Name.CompareTo(b.Name); } public override bool IsBusy @@ -642,34 +509,5 @@ private enum BranchesMode Default, Create } - - [Serializable] - private class BranchTreeNode - { - private readonly List children = new List(); - - public string Label; - public BranchTreeNode Tracking; - - public BranchTreeNode(string name, NodeType type, bool active) - { - Label = Name = name; - Type = type; - Active = active; - } - - public string Name { get; private set; } - public NodeType Type { get; private set; } - public bool Active { get; private set; } - - public IList Children { get { return children; } } - } - - private struct Remote - { - // TODO: Pull in and store more data from GitListRemotesTask - public string Name; - public BranchTreeNode Root; - } } } diff --git a/src/UnityExtension/Assets/Editor/GitHub.Unity/UI/TreeControl.cs b/src/UnityExtension/Assets/Editor/GitHub.Unity/UI/TreeControl.cs new file mode 100644 index 000000000..09a066402 --- /dev/null +++ b/src/UnityExtension/Assets/Editor/GitHub.Unity/UI/TreeControl.cs @@ -0,0 +1,541 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using UnityEditor; +using UnityEngine; +using UnityEngine.Profiling; + +namespace GitHub.Unity +{ + [Serializable] + public class TreeNodeDictionary : SerializableDictionary { } + + [Serializable] + public abstract class Tree + { + public static float ItemHeight { get { return EditorGUIUtility.singleLineHeight; } } + public static float ItemSpacing { get { return EditorGUIUtility.standardVerticalSpacing; } } + + [SerializeField] public Rect Margin = new Rect(); + [SerializeField] public Rect Padding = new Rect(); + + [NonSerialized] public GUIStyle FolderStyle; + [NonSerialized] public GUIStyle TreeNodeStyle; + [NonSerialized] public GUIStyle ActiveTreeNodeStyle; + + [SerializeField] private List nodes = new List(); + [SerializeField] private TreeNode selectedNode = null; + [SerializeField] private TreeNode activeNode = null; + [SerializeField] private TreeNodeDictionary folders = new TreeNodeDictionary(); + + [NonSerialized] private Stack indents = new Stack(); + [NonSerialized] private Action rightClickNextRender; + [NonSerialized] private TreeNode rightClickNextRenderNode; + + public bool IsInitialized { get { return nodes != null && nodes.Count > 0 && !String.IsNullOrEmpty(nodes[0].Name); } } + public bool RequiresRepaint { get; private set; } + + public TreeNode SelectedNode + { + get + { + if (selectedNode != null && String.IsNullOrEmpty(selectedNode.Name)) + selectedNode = null; + return selectedNode; + } + private set + { + selectedNode = value; + } + } + + public TreeNode ActiveNode { get { return activeNode; } } + + public void Load(IEnumerable data, string title) + { + var collapsedFoldersEnumerable = folders.Where(pair => pair.Value.IsCollapsed).Select(pair => pair.Key); + var collapsedFolders = new HashSet(collapsedFoldersEnumerable); + string selectedNodeName = null; + if (SelectedNode != null) + { + selectedNodeName = SelectedNode.Name; + SelectedNode = null; + } + + folders.Clear(); + nodes.Clear(); + + var titleNode = new TreeNode() + { + Name = title, + Label = title, + Level = 0, + IsFolder = true + }; + SetNodeIcon(titleNode); + nodes.Add(titleNode); + + var hideChildren = false; + var hideChildrenBelowLevel = 0; + + foreach (var d in data) + { + var parts = d.Name.Split('/'); + for (int i = 0; i < parts.Length; i++) + { + var label = parts[i]; + var level = i + 1; + var name = String.Join("/", parts, 0, level); + var isFolder = i < parts.Length - 1; + var alreadyExists = folders.ContainsKey(name); + if (!alreadyExists) + { + var node = new TreeNode + { + Name = name, + IsActive = d.IsActive, + Label = label, + Level = level, + IsFolder = isFolder + }; + + if (selectedNodeName != null && name == selectedNodeName) + { + SelectedNode = node; + } + + if (node.IsActive) + { + activeNode = node; + } + + if (hideChildren) + { + if (level <= hideChildrenBelowLevel) + { + hideChildren = false; + } + else + { + node.IsHidden = true; + } + } + + SetNodeIcon(node); + + nodes.Add(node); + if (isFolder) + { + if (collapsedFolders.Contains(name)) + { + node.IsCollapsed = true; + + if (!hideChildren) + { + hideChildren = true; + hideChildrenBelowLevel = level; + } + } + + folders.Add(name, node); + } + } + } + } + } + + public Rect Render(Rect containingRect, Rect rect, Vector2 scroll, Action singleClick = null, Action doubleClick = null, Action rightClick = null) + { + if (Event.current.type != EventType.Repaint) + { + if (rightClickNextRender != null) + { + rightClickNextRender.Invoke(rightClickNextRenderNode); + rightClickNextRender = null; + rightClickNextRenderNode = null; + } + } + + var startDisplay = scroll.y; + var endDisplay = scroll.y + containingRect.height; + + RequiresRepaint = false; + rect = new Rect(0f, rect.y, rect.width, ItemHeight); + + var titleNode = nodes[0]; + var selectionChanged = false; + + var titleDisplay = !(rect.y > endDisplay || rect.yMax < startDisplay); + if (titleDisplay) + { + selectionChanged = titleNode.Render(rect, Styles.TreeIndentation, selectedNode == titleNode, FolderStyle, TreeNodeStyle, ActiveTreeNodeStyle); + } + + if (selectionChanged) + { + ToggleNodeVisibility(0, titleNode); + } + + RequiresRepaint = HandleInput(rect, titleNode, 0); + rect.y += ItemHeight + ItemSpacing; + + Indent(); + + int level = 1; + int i = 1; + for (; i < nodes.Count; i++) + { + var node = nodes[i]; + if (node.Level > level && !node.IsHidden) + { + Indent(); + } + + var changed = false; + + var display = !(rect.y > endDisplay || rect.yMax < startDisplay); + if (display) + { + changed = node.Render(rect, Styles.TreeIndentation, selectedNode == node, FolderStyle, TreeNodeStyle, ActiveTreeNodeStyle); + } + + if (node.IsFolder && changed) + { + // toggle visibility for all the nodes under this one + ToggleNodeVisibility(i, node); + } + + if (node.Level < level) + { + for (; node.Level > level && indents.Count > 1; level--) + { + Unindent(); + } + } + level = node.Level; + + if (!node.IsHidden) + { + RequiresRepaint = HandleInput(rect, node, i, singleClick, doubleClick, rightClick); + rect.y += ItemHeight + ItemSpacing; + } + } + + Unindent(); + + Profiler.EndSample(); + return rect; + } + + public void Focus() + { + bool selectionChanged = false; + if (Event.current.type == EventType.KeyDown) + { + int directionY = Event.current.keyCode == KeyCode.UpArrow ? -1 : Event.current.keyCode == KeyCode.DownArrow ? 1 : 0; + int directionX = Event.current.keyCode == KeyCode.LeftArrow ? -1 : Event.current.keyCode == KeyCode.RightArrow ? 1 : 0; + + if (directionY < 0 || directionX < 0) + { + SelectedNode = nodes[nodes.Count - 1]; + selectionChanged = true; + Event.current.Use(); + } + else if (directionY > 0 || directionX > 0) + { + SelectedNode = nodes[0]; + selectionChanged = true; + Event.current.Use(); + } + } + RequiresRepaint = selectionChanged; + } + + public void Blur() + { + SelectedNode = null; + RequiresRepaint = true; + } + + private int ToggleNodeVisibility(int idx, TreeNode rootNode) + { + var rootNodeLevel = rootNode.Level; + rootNode.IsCollapsed = !rootNode.IsCollapsed; + idx++; + for (; idx < nodes.Count && nodes[idx].Level > rootNodeLevel; idx++) + { + nodes[idx].IsHidden = rootNode.IsCollapsed; + if (nodes[idx].IsFolder && !rootNode.IsCollapsed && nodes[idx].IsCollapsed) + { + var level = nodes[idx].Level; + for (idx++; idx < nodes.Count && nodes[idx].Level > level; idx++) { } + idx--; + } + } + if (SelectedNode != null && SelectedNode.IsHidden) + { + SelectedNode = rootNode; + } + return idx; + } + + private bool HandleInput(Rect rect, TreeNode currentNode, int index, Action singleClick = null, Action doubleClick = null, Action rightClick = null) + { + bool selectionChanged = false; + var clickRect = new Rect(0f, rect.y, rect.width, rect.height); + if (Event.current.type == EventType.MouseDown && clickRect.Contains(Event.current.mousePosition)) + { + Event.current.Use(); + SelectedNode = currentNode; + selectionChanged = true; + var clickCount = Event.current.clickCount; + var mouseButton = Event.current.button; + + if (mouseButton == 0 && clickCount == 1 && singleClick != null) + { + singleClick(currentNode); + } + if (mouseButton == 0 && clickCount > 1 && doubleClick != null) + { + doubleClick(currentNode); + } + if (mouseButton == 1 && clickCount == 1 && rightClick != null) + { + rightClickNextRender = rightClick; + rightClickNextRenderNode = currentNode; + } + } + + // Keyboard navigation if this child is the current selection + if (currentNode == selectedNode && Event.current.type == EventType.KeyDown) + { + int directionY = Event.current.keyCode == KeyCode.UpArrow ? -1 : Event.current.keyCode == KeyCode.DownArrow ? 1 : 0; + int directionX = Event.current.keyCode == KeyCode.LeftArrow ? -1 : Event.current.keyCode == KeyCode.RightArrow ? 1 : 0; + if (directionY != 0 || directionX != 0) + { + if (directionY > 0) + { + selectionChanged = SelectNext(index, false) != index; + } + else if (directionY < 0) + { + selectionChanged = SelectPrevious(index, false) != index; + } + else if (directionX > 0) + { + if (currentNode.IsFolder && currentNode.IsCollapsed) + { + ToggleNodeVisibility(index, currentNode); + Event.current.Use(); + } + else + { + selectionChanged = SelectNext(index, true) != index; + } + } + else if (directionX < 0) + { + if (currentNode.IsFolder && !currentNode.IsCollapsed) + { + ToggleNodeVisibility(index, currentNode); + Event.current.Use(); + } + else + { + selectionChanged = SelectPrevious(index, true) != index; + } + } + } + } + return selectionChanged; + } + + private int SelectNext(int index, bool foldersOnly) + { + for (index++; index < nodes.Count; index++) + { + if (nodes[index].IsHidden) + continue; + if (!nodes[index].IsFolder && foldersOnly) + continue; + break; + } + + if (index < nodes.Count) + { + SelectedNode = nodes[index]; + Event.current.Use(); + } + else + { + SelectedNode = null; + } + return index; + } + + private int SelectPrevious(int index, bool foldersOnly) + { + for (index--; index >= 0; index--) + { + if (nodes[index].IsHidden) + continue; + if (!nodes[index].IsFolder && foldersOnly) + continue; + break; + } + + if (index >= 0) + { + SelectedNode = nodes[index]; + Event.current.Use(); + } + else + { + SelectedNode = null; + } + return index; + } + + private void Indent() + { + indents.Push(true); + } + + private void Unindent() + { + indents.Pop(); + } + + private void SetNodeIcon(TreeNode node) + { + node.Icon = GetNodeIcon(node); + node.Load(); + } + + protected abstract Texture2D GetNodeIcon(TreeNode node); + + protected void LoadNodeIcons() + { + foreach (var treeNode in nodes) + { + SetNodeIcon(treeNode); + } + } + } + + [Serializable] + public class TreeNode + { + public string Name; + public string Label; + public int Level; + public bool IsFolder; + public bool IsCollapsed; + public bool IsHidden; + public bool IsActive; + public GUIContent content; + [NonSerialized] public Texture2D Icon; + + public void Load() + { + content = new GUIContent(Label, Icon); + } + + public bool Render(Rect rect, float indentation, bool isSelected, GUIStyle folderStyle, GUIStyle nodeStyle, GUIStyle activeNodeStyle) + { + if (IsHidden) + return false; + + GUIStyle style; + if (IsFolder) + { + style = folderStyle; + } + else + { + style = IsActive ? activeNodeStyle : nodeStyle; + } + + bool changed = false; + var fillRect = rect; + var nodeRect = new Rect(Level * indentation, rect.y, rect.width, rect.height); + + if (Event.current.type == EventType.repaint) + { + nodeStyle.Draw(fillRect, GUIContent.none, false, false, false, isSelected); + if (IsFolder) + { + style.Draw(nodeRect, content, false, false, !IsCollapsed, isSelected); + } + else + { + style.Draw(nodeRect, content, false, false, false, isSelected); + } + } + + if (IsFolder) + { + var toggleRect = new Rect(nodeRect.x, nodeRect.y, style.border.horizontal, nodeRect.height); + + EditorGUI.BeginChangeCheck(); + GUI.Toggle(toggleRect, !IsCollapsed, GUIContent.none, GUIStyle.none); + changed = EditorGUI.EndChangeCheck(); + } + + return changed; + } + + public override string ToString() + { + return String.Format("name:{0} label:{1} level:{2} isFolder:{3} isCollapsed:{4} isHidden:{5} isActive:{6}", + Name, Label, Level, IsFolder, IsCollapsed, IsHidden, IsActive); + } + } + + [Serializable] + public class BranchesTree: Tree + { + [SerializeField] public bool IsRemote; + + [NonSerialized] public Texture2D ActiveBranchIcon; + [NonSerialized] public Texture2D BranchIcon; + [NonSerialized] public Texture2D FolderIcon; + [NonSerialized] public Texture2D GlobeIcon; + + protected override Texture2D GetNodeIcon(TreeNode node) + { + Texture2D nodeIcon; + if (node.IsActive) + { + nodeIcon = ActiveBranchIcon; + } + else if (node.IsFolder) + { + nodeIcon = IsRemote && node.Level == 1 + ? GlobeIcon + : FolderIcon; + } + else + { + nodeIcon = BranchIcon; + } + return nodeIcon; + } + + + public void UpdateIcons(Texture2D activeBranchIcon, Texture2D branchIcon, Texture2D folderIcon, Texture2D globeIcon) + { + var needsLoad = ActiveBranchIcon == null || BranchIcon == null || FolderIcon == null || GlobeIcon == null; + if (needsLoad) + { + ActiveBranchIcon = activeBranchIcon; + BranchIcon = branchIcon; + FolderIcon = folderIcon; + GlobeIcon = globeIcon; + + LoadNodeIcons(); + } + } + } +} diff --git a/src/UnityExtension/Assets/Editor/GitHub.Unity/UI/Window.cs b/src/UnityExtension/Assets/Editor/GitHub.Unity/UI/Window.cs index 41e283aba..e741af05e 100644 --- a/src/UnityExtension/Assets/Editor/GitHub.Unity/UI/Window.cs +++ b/src/UnityExtension/Assets/Editor/GitHub.Unity/UI/Window.cs @@ -58,6 +58,15 @@ public static void GitHub_CommandLine() EntryPoint.ApplicationManager.ProcessManager.RunCommandLineWindow(NPath.CurrentDirectory); } +#if DEBUG + [MenuItem("GitHub/Select Window")] + public static void GitHub_SelectWindow() + { + var window = Resources.FindObjectsOfTypeAll(typeof(Window)).FirstOrDefault() as Window; + Selection.activeObject = window; + } +#endif + public static void ShowWindow(IApplicationManager applicationManager) { var type = typeof(EditorWindow).Assembly.GetType("UnityEditor.InspectorWindow");