diff --git a/src/GitHub.Api/Application/ApiClient.cs b/src/GitHub.Api/Application/ApiClient.cs index 3ef20f9ce..161540e41 100644 --- a/src/GitHub.Api/Application/ApiClient.cs +++ b/src/GitHub.Api/Application/ApiClient.cs @@ -469,20 +469,20 @@ private GitHubUser GetValidatedGitHubUser() } } - class GitHubHostMeta + public class GitHubHostMeta { public bool VerifiablePasswordAuthentication { get; set; } public string GithubServicesSha { get; set; } public string InstalledVersion { get; set; } } - class GitHubUser + public class GitHubUser { public string Name { get; set; } public string Login { get; set; } } - class GitHubRepository + public class GitHubRepository { public string Name { get; set; } public string CloneUrl { get; set; } diff --git a/src/GitHub.Api/Application/IApiClient.cs b/src/GitHub.Api/Application/IApiClient.cs index 83bfa17c9..09c79611f 100644 --- a/src/GitHub.Api/Application/IApiClient.cs +++ b/src/GitHub.Api/Application/IApiClient.cs @@ -2,7 +2,7 @@ namespace GitHub.Unity { - interface IApiClient + public interface IApiClient { HostAddress HostAddress { get; } void CreateRepository(string name, string description, bool isPrivate, diff --git a/src/GitHub.Api/Application/Organization.cs b/src/GitHub.Api/Application/Organization.cs index e78849dd6..8deea7d99 100644 --- a/src/GitHub.Api/Application/Organization.cs +++ b/src/GitHub.Api/Application/Organization.cs @@ -1,8 +1,8 @@ namespace GitHub.Unity { - class Organization + public class Organization { public string Name { get; set; } public string Login { get; set; } } -} \ No newline at end of file +} diff --git a/src/GitHub.Api/Git/GitClient.cs b/src/GitHub.Api/Git/GitClient.cs index a59d4d37b..6b77866cb 100644 --- a/src/GitHub.Api/Git/GitClient.cs +++ b/src/GitHub.Api/Git/GitClient.cs @@ -208,6 +208,15 @@ public interface IGitClient /// String output of git command ITask DiscardAll(IOutputProcessor processor = null); + /// + /// Executes at least one `git checkout` command to checkout files at the given changeset + /// + /// The md5 of the changeset + /// The files to check out + /// A custom output processor instance + /// String output of git command + ITask CheckoutVersion(string changeset, IList files, IOutputProcessor processor = null); + /// /// Executes at least one `git reset HEAD` command to remove files from the git index. /// @@ -250,6 +259,13 @@ public interface IGitClient /// of output ITask> Log(BaseOutputListProcessor processor = null); + /// + /// Executes `git log -- ` to get the history of a specific file. + /// + /// A custom output processor instance + /// of output + ITask> LogFile(NPath file, BaseOutputListProcessor processor = null); + /// /// Executes `git --version` to get the git version. /// @@ -341,6 +357,17 @@ public ITask> Log(BaseOutputListProcessor process .Then((success, list) => success ? list : new List()); } + /// + public ITask> LogFile(NPath file, BaseOutputListProcessor processor = null) + { + return new GitLogTask(file, new GitObjectFactory(environment), cancellationToken, processor) + .Configure(processManager) + .Catch(exception => exception is ProcessException && + exception.Message.StartsWith("fatal: your current branch") && + exception.Message.EndsWith("does not have any commits yet")) + .Then((success, list) => success ? list : new List()); + } + /// public ITask Version(IOutputProcessor processor = null) { @@ -565,6 +592,13 @@ public ITask DiscardAll(IOutputProcessor processor = null) .Configure(processManager); } + /// + public ITask CheckoutVersion(string changeset, IList files, IOutputProcessor processor = null) + { + return new GitCheckoutTask(changeset, files, cancellationToken, processor) + .Configure(processManager); + } + /// public ITask Remove(IList files, IOutputProcessor processor = null) diff --git a/src/GitHub.Api/Git/Tasks/GitCheckoutTask.cs b/src/GitHub.Api/Git/Tasks/GitCheckoutTask.cs index ea2e9f4d3..d1d050b52 100644 --- a/src/GitHub.Api/Git/Tasks/GitCheckoutTask.cs +++ b/src/GitHub.Api/Git/Tasks/GitCheckoutTask.cs @@ -30,8 +30,29 @@ public GitCheckoutTask(CancellationToken token, arguments = "checkout -- ."; } + public GitCheckoutTask( + string changeset, + IEnumerable files, + CancellationToken token, + IOutputProcessor processor = null) : base(token, processor ?? new SimpleOutputProcessor()) + { + Guard.ArgumentNotNull(files, "files"); + Name = TaskName; + + arguments = "checkout "; + arguments += changeset; + arguments += " -- "; + + foreach (var file in files) + { + arguments += " \"" + file.ToNPath().ToString(SlashMode.Forward) + "\""; + } + + Message = "Checking out files at rev " + changeset.Substring(0, 7); + } + public override string ProcessArguments { get { return arguments; } } public override TaskAffinity Affinity { get { return TaskAffinity.Exclusive; } } - public override string Message { get; set; } = "Checking out branch..."; + public override string Message { get; set; } = "Checking out files..."; } -} \ No newline at end of file +} diff --git a/src/GitHub.Api/Git/Tasks/GitLogTask.cs b/src/GitHub.Api/Git/Tasks/GitLogTask.cs index 955521a61..a55e72e5c 100644 --- a/src/GitHub.Api/Git/Tasks/GitLogTask.cs +++ b/src/GitHub.Api/Git/Tasks/GitLogTask.cs @@ -5,17 +5,31 @@ namespace GitHub.Unity class GitLogTask : ProcessTaskWithListOutput { private const string TaskName = "git log"; + private const string baseArguments = @"-c i18n.logoutputencoding=utf8 -c core.quotepath=false log --pretty=format:""%H%n%P%n%aN%n%aE%n%aI%n%cN%n%cE%n%cI%n%B---GHUBODYEND---"" --name-status"; + private readonly string arguments; public GitLogTask(IGitObjectFactory gitObjectFactory, CancellationToken token, BaseOutputListProcessor processor = null) : base(token, processor ?? new LogEntryOutputProcessor(gitObjectFactory)) { Name = TaskName; + arguments = baseArguments; + } + + public GitLogTask(NPath file, + IGitObjectFactory gitObjectFactory, + CancellationToken token, BaseOutputListProcessor processor = null) + : base(token, processor ?? new LogEntryOutputProcessor(gitObjectFactory)) + { + Name = TaskName; + arguments = baseArguments; + arguments += " -- "; + arguments += " \"" + file.ToString(SlashMode.Forward) + "\""; } public override string ProcessArguments { - get { return @"-c i18n.logoutputencoding=utf8 -c core.quotepath=false log --pretty=format:""%H%n%P%n%aN%n%aE%n%aI%n%cN%n%cE%n%cI%n%B---GHUBODYEND---"" --name-status"; } + get { return arguments; } } public override string Message { get; set; } = "Loading the history..."; } diff --git a/src/UnityExtension/Assets/Editor/GitHub.Unity/ExtensionLoader/ExtensionLoader.asmdef b/src/UnityExtension/Assets/Editor/GitHub.Unity/ExtensionLoader/ExtensionLoader.asmdef index a5ed02083..1703a7af8 100644 --- a/src/UnityExtension/Assets/Editor/GitHub.Unity/ExtensionLoader/ExtensionLoader.asmdef +++ b/src/UnityExtension/Assets/Editor/GitHub.Unity/ExtensionLoader/ExtensionLoader.asmdef @@ -1,6 +1,6 @@ { "name": "ExtensionLoader", - "references": ["../../build/GitHub.UnityShim.dll"], + "precompiledReferences": ["../../build/GitHub.UnityShim.dll"], "includePlatforms": [ "Editor" ], diff --git a/src/UnityExtension/Assets/Editor/GitHub.Unity/GitHub.Unity.45.csproj b/src/UnityExtension/Assets/Editor/GitHub.Unity/GitHub.Unity.45.csproj index 7cf5bb05b..1bedc423f 100644 --- a/src/UnityExtension/Assets/Editor/GitHub.Unity/GitHub.Unity.45.csproj +++ b/src/UnityExtension/Assets/Editor/GitHub.Unity/GitHub.Unity.45.csproj @@ -90,6 +90,8 @@ + + diff --git a/src/UnityExtension/Assets/Editor/GitHub.Unity/GitHub.Unity.csproj b/src/UnityExtension/Assets/Editor/GitHub.Unity/GitHub.Unity.csproj index ae6f39583..28ca2c0d4 100644 --- a/src/UnityExtension/Assets/Editor/GitHub.Unity/GitHub.Unity.csproj +++ b/src/UnityExtension/Assets/Editor/GitHub.Unity/GitHub.Unity.csproj @@ -77,6 +77,8 @@ + + diff --git a/src/UnityExtension/Assets/Editor/GitHub.Unity/UI/BaseWindow.cs b/src/UnityExtension/Assets/Editor/GitHub.Unity/UI/BaseWindow.cs index 10d72469d..e0fe85c84 100644 --- a/src/UnityExtension/Assets/Editor/GitHub.Unity/UI/BaseWindow.cs +++ b/src/UnityExtension/Assets/Editor/GitHub.Unity/UI/BaseWindow.cs @@ -7,7 +7,7 @@ namespace GitHub.Unity { - abstract class BaseWindow : EditorWindow, IView + public abstract class BaseWindow : EditorWindow, IView { [NonSerialized] private bool initialized = false; [NonSerialized] private IUser cachedUser; diff --git a/src/UnityExtension/Assets/Editor/GitHub.Unity/UI/ContextMenu.cs b/src/UnityExtension/Assets/Editor/GitHub.Unity/UI/ContextMenu.cs new file mode 100644 index 000000000..2ca5eb783 --- /dev/null +++ b/src/UnityExtension/Assets/Editor/GitHub.Unity/UI/ContextMenu.cs @@ -0,0 +1,36 @@ +using System.Collections; +using System.Collections.Generic; +using UnityEngine; +using UnityEditor; + +namespace GitHub.Unity +{ + public class ContextMenu + { + [MenuItem("Assets/Git/History", false)] + private static void GitFileHistory() + { + if (Selection.assetGUIDs != null) + { + int maxWindowsToOpen = 10; + int windowsOpened = 0; + foreach(var guid in Selection.assetGUIDs) + { + var assetPath = AssetDatabase.GUIDToAssetPath(guid); + FileHistoryWindow.OpenWindow(assetPath); + windowsOpened++; + if (windowsOpened >= maxWindowsToOpen) + { + break; + } + } + } + } + + [MenuItem("Assets/Git/History", true)] + private static bool GitFileHistoryValidation() + { + return Selection.assetGUIDs != null && Selection.assetGUIDs.Length > 0; + } + } +} diff --git a/src/UnityExtension/Assets/Editor/GitHub.Unity/UI/FileHistoryWindow.cs b/src/UnityExtension/Assets/Editor/GitHub.Unity/UI/FileHistoryWindow.cs new file mode 100644 index 000000000..ee0bf05a3 --- /dev/null +++ b/src/UnityExtension/Assets/Editor/GitHub.Unity/UI/FileHistoryWindow.cs @@ -0,0 +1,290 @@ +using System.Collections.Generic; +using System.Linq; +using System; +using UnityEngine; +using UnityEditor; + +namespace GitHub.Unity +{ + public class FileHistoryWindow : BaseWindow + { + [SerializeField] private string assetPath; + [SerializeField] private List history; + [SerializeField] private Vector2 scroll; + [SerializeField] private Vector2 detailsScroll; + [NonSerialized] private bool busy; + [SerializeField] private HistoryControl historyControl; + [SerializeField] private GitLogEntry selectedEntry = GitLogEntry.Default; + [NonSerialized] private ChangesTree treeChanges; + + + private const string ConfirmCheckoutTitle = "Discard Changes?"; + private const string ConfirmCheckoutMessage = "There are modifications to file '{0}'; checking out a historical version will permanently overwite those changes. Continue?"; + private const string ConfirmCheckoutOK = "Overwrite"; + private const string ConfirmCheckoutCancel = "Cancel"; + + + public static FileHistoryWindow OpenWindow(string assetPath) + { + var popupWindow = CreateInstance(); + + popupWindow.titleContent = new GUIContent(assetPath + " History"); + popupWindow.Open(assetPath); + + popupWindow.Show(); + + return popupWindow; + } + + public override bool IsBusy { get { return this.busy; } } + + private NPath FullFilePath + { + get + { + return Application.dataPath.ToNPath().Parent.Combine(assetPath.ToNPath()); + } + } + + public void Open(string assetPath) + { + this.assetPath = assetPath; + + this.RefreshLog(); + } + + public void RefreshLog() + { + this.busy = true; + this.GitClient.LogFile(this.FullFilePath).ThenInUI((success, logEntries) => { + this.history = logEntries; + this.BuildHistoryControl(); + this.Repaint(); + this.busy = false; + }).Start(); + } + + + private void CheckoutVersion(string commitID) + { + this.busy = true; + this.GitClient.CheckoutVersion(commitID, new string[]{FullFilePath}).ThenInUI((success, result) => { + AssetDatabase.Refresh(); + this.busy = false; + }).Start(); + } + + private void Checkout() + { + GitClient.Status() + .ThenInUI((success, status) => + { + if (success) + { + bool promptUser = false; + + foreach (var entry in status.Entries) + { + if (entry.FullPath == this.FullFilePath) { + // local changes; prompt user before we checkout. + promptUser = true; + } + } + + if (!promptUser || EditorUtility.DisplayDialog(ConfirmCheckoutTitle, string.Format(ConfirmCheckoutMessage, this.assetPath), ConfirmCheckoutOK, ConfirmCheckoutCancel)) { + this.CheckoutVersion(this.selectedEntry.CommitID); + } + } + else + { + Debug.LogError("Error retrieving current repo status"); + } + }) + .Start(); + } + + public override void OnUI() + { + // TODO: + // - should handle case where the file is outside of the repository (handle exceptional cases) + // - should display a spinner while history is still loading... + base.OnUI(); + + GUILayout.BeginHorizontal(Styles.HeaderStyle); + { + GUILayout.Label("GIT File History for: ", Styles.BoldLabel); + if (HyperlinkLabel(this.assetPath)) + { + var asset = AssetDatabase.LoadMainAssetAtPath(this.assetPath); + Selection.activeObject = asset; + EditorGUIUtility.PingObject(asset); + } + GUILayout.FlexibleSpace(); + } + GUILayout.EndHorizontal(); + + if (historyControl != null) + { + var rect = GUILayoutUtility.GetLastRect(); + var historyControlRect = new Rect(0f, 0f, Position.width, Position.height - rect.height); + + var requiresRepaint = historyControl.Render(historyControlRect, + entry => { + selectedEntry = entry; + BuildTree(); + }, + entry => { }, entry => { + GenericMenu menu = new GenericMenu(); + menu.AddItem(new GUIContent("Checkout version " + entry.ShortID), false, Checkout); + menu.ShowAsContext(); + }); + + if (requiresRepaint) + Redraw(); + } + + // DrawDetails is maybe irrelevant? Would be a nice place to put the short id perhaps? + DrawDetails(); + } + + private bool HyperlinkLabel(string label) + { + bool returnValue = false; + if (GUILayout.Button(label, HyperlinkStyle)) + { + returnValue = true; + } + var rect = GUILayoutUtility.GetLastRect(); + var size = HyperlinkStyle.CalcSize(new GUIContent(label)); + rect.width = size.x; + EditorGUIUtility.AddCursorRect(rect, MouseCursor.Link); + return returnValue; + } + + private void BuildHistoryControl() + { + if (historyControl == null) + { + historyControl = new HistoryControl(); + } + + historyControl.Load(0, this.history); + } + + + private const string CommitDetailsTitle = "Commit details"; + private const string ClearSelectionButton = "×"; + + + private void DrawDetails() + { + if (!selectedEntry.Equals(GitLogEntry.Default)) + { + // Top bar for scrolling to selection or clearing it + GUILayout.BeginHorizontal(EditorStyles.toolbar); + { + if (GUILayout.Button(CommitDetailsTitle, Styles.ToolbarButtonStyle)) + { + historyControl.ScrollTo(historyControl.SelectedIndex); + } + if (GUILayout.Button(ClearSelectionButton, Styles.ToolbarButtonStyle, GUILayout.ExpandWidth(false))) + { + selectedEntry = GitLogEntry.Default; + historyControl.SelectedIndex = -1; + } + } + GUILayout.EndHorizontal(); + + // Log entry details - including changeset tree (if any changes are found) + detailsScroll = GUILayout.BeginScrollView(detailsScroll, GUILayout.Height(250)); + { + HistoryDetailsEntry(selectedEntry); + + GUILayout.Space(EditorGUIUtility.standardVerticalSpacing); + GUILayout.Label("Files changed", EditorStyles.boldLabel); + GUILayout.Space(-5); + + var rect = GUILayoutUtility.GetLastRect(); + GUILayout.BeginHorizontal(Styles.HistoryFileTreeBoxStyle); + GUILayout.BeginVertical(); + { + var borderLeft = Styles.Label.margin.left; + var treeControlRect = new Rect(rect.x + borderLeft, rect.y, Position.width - borderLeft * 2, Position.height - rect.height + Styles.CommitAreaPadding); + var treeRect = new Rect(0f, 0f, 0f, 0f); + + if (treeChanges == null) { // Can be null in the case of domain reloads + BuildTree(); + } + + treeChanges.FolderStyle = Styles.Foldout; + treeChanges.TreeNodeStyle = Styles.TreeNode; + treeChanges.ActiveTreeNodeStyle = Styles.ActiveTreeNode; + treeChanges.FocusedTreeNodeStyle = Styles.FocusedTreeNode; + treeChanges.FocusedActiveTreeNodeStyle = Styles.FocusedActiveTreeNode; + + treeRect = treeChanges.Render(treeControlRect, detailsScroll, + node => { + }, + node => { + }, + node => { + }); + + if (treeChanges.RequiresRepaint) + Redraw(); + + GUILayout.Space(treeRect.y - treeControlRect.y); + } + GUILayout.EndVertical(); + GUILayout.EndHorizontal(); + + GUILayout.Space(EditorGUIUtility.standardVerticalSpacing); + } + GUILayout.EndScrollView(); + } + } + + private void HistoryDetailsEntry(GitLogEntry entry) + { + GUILayout.BeginVertical(Styles.HeaderBoxStyle); + GUILayout.Label(entry.Summary, Styles.HistoryDetailsTitleStyle); + + GUILayout.Space(-5); + + GUILayout.BeginHorizontal(); + GUILayout.Label(entry.PrettyTimeString, Styles.HistoryDetailsMetaInfoStyle); + GUILayout.Label(entry.AuthorName, Styles.HistoryDetailsMetaInfoStyle); + GUILayout.FlexibleSpace(); + GUILayout.EndHorizontal(); + + GUILayout.Space(3); + GUILayout.EndVertical(); + } + + private void BuildTree() + { + treeChanges = new ChangesTree { + IsSelectable = false, + DisplayRootNode = false, + PathSeparator = Environment.FileSystem.DirectorySeparatorChar.ToString(), + }; + treeChanges.Load(selectedEntry.changes.Select(entry => new GitStatusEntryTreeData(entry))); + Redraw(); + } + + protected static GUIStyle hyperlinkStyle = null; + + public static GUIStyle HyperlinkStyle + { + get + { + if (hyperlinkStyle == null) + { + hyperlinkStyle = new GUIStyle(EditorStyles.wordWrappedLabel); + hyperlinkStyle.normal.textColor = new Color(95.0f/255.0f, 170.0f/255.0f, 247.0f/255.0f); + } + return hyperlinkStyle; + } + } + } +} \ No newline at end of file diff --git a/src/UnityExtension/Assets/Editor/GitHub.Unity/UI/HistoryView.cs b/src/UnityExtension/Assets/Editor/GitHub.Unity/UI/HistoryView.cs index 69b42e83e..afef175e8 100644 --- a/src/UnityExtension/Assets/Editor/GitHub.Unity/UI/HistoryView.cs +++ b/src/UnityExtension/Assets/Editor/GitHub.Unity/UI/HistoryView.cs @@ -7,7 +7,7 @@ namespace GitHub.Unity { [Serializable] - class HistoryControl + public class HistoryControl { private const string HistoryEntryDetailFormat = "{0} {1}"; @@ -320,7 +320,7 @@ class HistoryView : Subview [SerializeField] private int statusAhead; - [SerializeField] private ChangesTree treeChanges = new ChangesTree { IsSelectable = false, DisplayRootNode = false }; + [SerializeField] private ChangesTree treeChanges = new ChangesTree { DisplayRootNode = false }; [SerializeField] private CacheUpdateEvent lastLogChangedEvent; [SerializeField] private CacheUpdateEvent lastTrackingStatusChangedEvent; @@ -435,11 +435,13 @@ public override void OnGUI() treeChanges.FocusedActiveTreeNodeStyle = Styles.FocusedActiveTreeNode; treeRect = treeChanges.Render(treeControlRect, detailsScroll, - node => { }, - node => { - }, - node => { - }); + singleClick: node => { }, + doubleClick: node => { }, + rightClick: node => { + var menu = CreateChangesTreeContextMenu(node); + menu.ShowAsContext(); + } + ); if (treeChanges.RequiresRepaint) Redraw(); @@ -588,5 +590,14 @@ private void BuildTree() treeChanges.Load(selectedEntry.changes.Select(entry => new GitStatusEntryTreeData(entry))); Redraw(); } + + private GenericMenu CreateChangesTreeContextMenu(ChangesTreeNode node) + { + var genericMenu = new GenericMenu(); + + genericMenu.AddItem(new GUIContent("Show History"), false, () => { }); + + return genericMenu; + } } } diff --git a/src/UnityExtension/Assets/Editor/GitHub.Unity/UI/PopupWindow.cs b/src/UnityExtension/Assets/Editor/GitHub.Unity/UI/PopupWindow.cs index abeff1ab4..406a5e178 100644 --- a/src/UnityExtension/Assets/Editor/GitHub.Unity/UI/PopupWindow.cs +++ b/src/UnityExtension/Assets/Editor/GitHub.Unity/UI/PopupWindow.cs @@ -7,7 +7,7 @@ namespace GitHub.Unity { [Serializable] - class PopupWindow : BaseWindow + public class PopupWindow : BaseWindow { public enum PopupViewType { diff --git a/src/UnityExtension/Assets/Editor/UnityTests/UnityTests.asmdef b/src/UnityExtension/Assets/Editor/UnityTests/UnityTests.asmdef index eef024216..d49630a1a 100644 --- a/src/UnityExtension/Assets/Editor/UnityTests/UnityTests.asmdef +++ b/src/UnityExtension/Assets/Editor/UnityTests/UnityTests.asmdef @@ -3,8 +3,16 @@ "references": [ "GitHub.Unity" ], + "optionalUnityReferences": [ + "TestAssemblies" + ], "includePlatforms": [ "Editor" ], - "excludePlatforms": [] + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [] } \ No newline at end of file