Project Window Extension Script: "Folders" sorting, double-click expand/collapse

This script related to feature request:

Double-click expand/collapse feature request:

Made for Unity 3D v4.1.2.

Features:

  1. “Folders” are sorted separately from “Files”, like in Visual Studio. Sorting works only for Ptoject Window “One Column Layout”.
  2. Double-click “Folders” expand/collapse, like in Visual Studio.

Note:
This script is unusual and using reflection, because Unity scripting doesn’t support Project Window extending in that way. So i was lucky to be able to find the way of doing it, this also means that in future versions of Unity this script may not work.

Also would be nice to implement and test:

  • Sort files by extensions.

v1.0
ProjectWindowExtension.cs

/*
 * Project Window Extension v1.0
 *
 * Copyright (c) 2013 newbprofi
 * 
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 * 
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 * 
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
*/

using System;
using System.Reflection;
using UnityEngine;
using UnityEditor;
using System.Collections.Generic;
using System.Collections;

[InitializeOnLoad]
public static class ProjectWindowExtension
{
	static Assembly unityEditorAssembly;

	static Type objectBrowserType;
	static Type treeViewType;
	static Type treeViewDataType;
	static Type defaultTreeViewDataSourceType;
	static Type nodeType;

	static MethodInfo isFolderMethod;
	static MethodInfo findNodeByIDMethod;
	static MethodInfo isExpandableMethod;
	static MethodInfo isExpandedMethod;
	static MethodInfo setExpandedWithChildrenMethod;
	static MethodInfo setExpandedMethod;
	static MethodInfo getInstanceIDFromGUIDMethod;

	static FieldInfo objectBrowserField;
	static FieldInfo assetTreeField;
	static FieldInfo folderTreeField;
	static FieldInfo treeDataField;
	static FieldInfo visibleRowsField;
	static FieldInfo nodeInstanceIDField;
	static FieldInfo nodeDepthField;

	static object objectBrowser;
	static object assetTree;
	static object folderTree;
	static object treeData;

	static int lastVisibleRowsHash;

	static ProjectWindowExtension()
	{
		bool result = (unityEditorAssembly = Assembly.GetAssembly(typeof(Editor))) != null;
		if(result)
		{
			result = (objectBrowserType = unityEditorAssembly.GetType("UnityEditor.ObjectBrowser")) != null;
			result = (treeViewType = unityEditorAssembly.GetType("UnityEditor.TreeView")) != null;
			result = (treeViewDataType = unityEditorAssembly.GetType("UnityEditor.ITreeViewDataSource")) != null;
			result = (defaultTreeViewDataSourceType = unityEditorAssembly.GetType("UnityEditor.DefaultTreeViewDataSource")) != null;
			result = (nodeType = unityEditorAssembly.GetType("UnityEditor.TreeView+Node")) != null;
			if(result)
			{
				result = (isFolderMethod = objectBrowserType.GetMethod("IsFolder", BindingFlags.Static | BindingFlags.Public)) != null;
				result = (findNodeByIDMethod = treeViewDataType.GetMethod("FindNodeByID", BindingFlags.Instance | BindingFlags.Public)) != null;
				result = (isExpandableMethod = treeViewDataType.GetMethod("IsExpandable", BindingFlags.Instance | BindingFlags.Public)) != null;
				result = (isExpandedMethod = treeViewDataType.GetMethod("IsExpanded", BindingFlags.Instance | BindingFlags.Public)) != null;
				result = (setExpandedWithChildrenMethod = treeViewDataType.GetMethod("SetExpandedWithChildren", BindingFlags.Instance | BindingFlags.Public)) != null;
				result = (setExpandedMethod = treeViewDataType.GetMethod("SetExpanded", BindingFlags.Instance | BindingFlags.Public)) != null;
				result = (getInstanceIDFromGUIDMethod = typeof(AssetDatabase).GetMethod("GetInstanceIDFromGUID", BindingFlags.Static | BindingFlags.NonPublic)) != null;

				result = (objectBrowserField = objectBrowserType.GetField("s_LastInteractedObjectBrowser", BindingFlags.Static | BindingFlags.Public)) != null;
				result = (assetTreeField = objectBrowserType.GetField("m_AssetTree", BindingFlags.Instance | BindingFlags.NonPublic)) != null;
				result = (folderTreeField = objectBrowserType.GetField("m_FolderTree", BindingFlags.Instance | BindingFlags.NonPublic)) != null;
				result = (treeDataField = treeViewType.GetField("m_Data", BindingFlags.Instance | BindingFlags.Public)) != null;
				result = (visibleRowsField = defaultTreeViewDataSourceType.GetField("m_VisibleRows", BindingFlags.Instance | BindingFlags.NonPublic)) != null;
				result = (nodeInstanceIDField = nodeType.GetField("m_InstanceID", BindingFlags.Instance | BindingFlags.NonPublic)) != null;
				result = (nodeDepthField = nodeType.GetField("m_Depth", BindingFlags.Instance | BindingFlags.NonPublic)) != null;
				if(result)
				{
					EditorApplication.projectWindowItemOnGUI += ProjectWindowItem_OnGUI;
					EditorApplication.projectWindowChanged += ProjectWindow_Changed;
				}
			}
		}
	}

	static bool IsFolder(object node)
	{
		int instanceID = (int)nodeInstanceIDField.GetValue(node);
		bool isFolder = (bool)isFolderMethod.Invoke(null, new object[] { instanceID });
		return isFolder;
	}

	static int SortRecursiveByDepth(IList sortedList, IList list, int position, int depth, bool needFolders)
	{
		int pos = position;
		int count = list.Count;
		bool lastIsFolder = !needFolders;

		while(pos < count  sortedList.Count < count)
		{
			object node = list[pos];
			int nodeDepth = (int)nodeDepthField.GetValue(list[pos]);

			// sort folders or files only
			if(needFolders)
			{
				if(nodeDepth == depth)
				{
					lastIsFolder = IsFolder(node);
					if(lastIsFolder)
						sortedList.Add(node);

					pos++;
				}
				else if(nodeDepth > depth)
				{
					if(lastIsFolder)
						pos = SortRecursiveByDepth(sortedList, list, pos, nodeDepth, true);
					else
						pos++;
				}
				else
					break;
			}
			else
			{
				if(nodeDepth == depth)
				{
					lastIsFolder = IsFolder(node);
					if(!lastIsFolder)
						sortedList.Add(node);

					pos++;
				}
				else if(nodeDepth > depth)
				{
					if(!lastIsFolder)
						pos = SortRecursiveByDepth(sortedList, list, pos, nodeDepth, false);
					else
						pos++;
				}
				else
					break;
			}
		}

		// sort files
		if(needFolders  sortedList.Count < count)
			pos = SortRecursiveByDepth(sortedList, list, position, depth, false);

		return pos;
	}

	static void SortAssetTree()
	{
		if(treeData != null  assetTree != null)
		{
			IList visibleRows = (IList)visibleRowsField.GetValue(treeData);
			if(visibleRows != null)
			{
				// check if was reallocated
				if(lastVisibleRowsHash != visibleRows.GetHashCode())
				{
					lastVisibleRowsHash = visibleRows.GetHashCode();

					// sort
					ArrayList sortedList = new ArrayList(visibleRows.Count);
					SortRecursiveByDepth(sortedList, visibleRows, 0, 0, true);

					// rewrite with sorted list
					for(int i = 0; i < visibleRows.Count; i++)
						visibleRows[i] = sortedList[i];
				}
			}
		}
	}

	static void InitObjects()
	{
		// this objects changed when project changed and not only
		assetTree = null;
		folderTree = null;
		treeData = null;
		objectBrowser = objectBrowserField.GetValue(null);
		if(objectBrowser != null)
		{
			assetTree = assetTreeField.GetValue(objectBrowser);
			folderTree = folderTreeField.GetValue(objectBrowser);

			if(assetTree != null)
				treeData = treeDataField.GetValue(assetTree);
			else if(folderTree != null)
				treeData = treeDataField.GetValue(folderTree);
		}
	}

	static void ProjectWindow_Changed()
	{
		InitObjects();
	}

	static void ProjectWindowItem_OnGUI(string guid, Rect drawingRect)
	{
		InitObjects();
		SortAssetTree();

		if(Event.current.type == EventType.MouseDown
			 Event.current.clickCount == 2
			 drawingRect.Contains(Event.current.mousePosition))
		{
			if(treeData != null)
			{
				int instanceID = (int)getInstanceIDFromGUIDMethod.Invoke(null, new object[] { guid });
				object node = findNodeByIDMethod.Invoke(treeData, new object[] { instanceID });
				if(node != null  IsFolder(node))
				{
					bool isExpandable = (bool)isExpandableMethod.Invoke(treeData, new object[] { node });
					bool isExpanded = (bool)isExpandedMethod.Invoke(treeData, new object[] { node });
					if(isExpandable)
					{
						if(Event.current.alt)
						{
							if(isExpanded)
								setExpandedWithChildrenMethod.Invoke(treeData, new object[] { node, false });
							else
								setExpandedWithChildrenMethod.Invoke(treeData, new object[] { node, true });
						}
						else
						{
							if(isExpanded)
								setExpandedMethod.Invoke(treeData, new object[] { node, false });
							else
								setExpandedMethod.Invoke(treeData, new object[] { node, true });
						}
					}

					//Event.current.Use();
				}
			}
		}
	}
}

EDIT: Actually, i found that Unity’s default sorting is not so bad. I found that when everything is sorted alphabetically - its easier for brain to find things. You just have to make good hierarchy of folders to not allow “folders” and “files” mix very often. So when you adding “folder” sorting you also adding a little brain pain, because you have to always remember, that “folders” on top and “files” bottom. But it is still very useful and i think there must be an option to switch sorting.

6 Likes

This is awesome! Great work.

Can you add support for the “Two Columns Layout”?

I’ll try to add it myself if I find the time.

How to install it ?

[edit]
Place it in a folder named “Editor”

it works perfectly thnx a lot, I’ve finally found the peace of sense :slight_smile:

Man this is just awesome!

I did a similar thing maybe a year ago but it didn’t work that well. And the sorting - super simple yet making my life easier. Thanks!

Is there a way to also expand prefabs?

If all you want to do is collapse or open all sub folders (including prefab contents), you can just alt + click on the parent folder. No scripts required.

Hi, maybe this is my browser problem, but I found that all the && (AND operators) are gone from the snippet (I am using Mac with Chrome browser)

So if you guys facing the compiling errors when copy/paste, check whether the && operators are missing :slight_smile:

P.s: All the result = … statements in the constructor (except the first) are actually result &= … statements (& sign missing)

2 Likes

Sorry to raise this post, but some code was found on the unity answers forum that supports 1 and 2 column, original credit goes to @WeslomPo

    using System;
    using System.Collections;
    using System.Collections.Generic;
    using System.Linq;
    using UnityEngine;
    using UnityEditor;
    using System.Reflection;
   
    [InitializeOnLoad]
    public static class ProjectBrowserExtension
    {
        private const string k_UnityEditorProjectBrowserAssemblyName = "UnityEditor.ProjectBrowser";
        private const string k_ProjectBrowsersFieldName = "s_ProjectBrowsers";
        private const string k_AssetTreeFieldName = "m_AssetTree";
        private const string k_ListAreaFieldName = "m_ListArea";
        private const string k_DataFieldName = "data";
        private const string k_FoldersFirstFieldName = "foldersFirst";
   
        private static readonly object s_BoolTrue = true;
       
        static ProjectBrowserExtension()
         {
             EditorApplication.projectChanged += OnChanged;
             EditorApplication.playModeStateChanged += OnPlayMode;
             EditorApplication.projectWindowItemOnGUI += OnFirstTime;
         }
         private static void OnFirstTime(string guid, Rect _)
         {
             EditorApplication.projectWindowItemOnGUI -= OnFirstTime;
             Refresh();
         }
         private static void OnChanged() => Refresh();
         private static void OnPlayMode(PlayModeStateChange obj) => Refresh();
        
         /// <summary>
         /// foreach browser in UnityEditor.ProjectBrowser.s_ProjectBrowsers
         ///     browser.m_AssetTree.data.foldersFirst = true
         ///     browser.m_ListArea.foldersFirst = true
         /// </summary>
         private static void Refresh()
         {
             Assembly assembly = Assembly.GetAssembly(typeof(UnityEditor.Editor));
             Type projectBrowser = assembly.GetType(k_UnityEditorProjectBrowserAssemblyName);
             FieldInfo field = projectBrowser.GetField(k_ProjectBrowsersFieldName, BindingFlags.Static | BindingFlags.NonPublic);
             if (field == null)
                 return;
             IEnumerable list = (IEnumerable) field.GetValue(projectBrowser);
             foreach (object pb in list)
                 SetFolderFirstForProjectWindow(pb);
         }
         private static void SetFolderFirstForProjectWindow(object pb)
         {
             IEnumerable<FieldInfo> members = pb.GetType().GetRuntimeFields();
             int maxMembersSought = 2;
             foreach (FieldInfo member in members)
             {
                 switch (member.Name)
                 {
                     // One column
                     case k_AssetTreeFieldName:
                         SetOneColumnFolderFirst(pb, member);
                         maxMembersSought--;
                         break;
                     // Two column
                     case k_ListAreaFieldName:
                         SetTwoColumnFolderFirst(pb, member);
                         maxMembersSought--;
                         break;
                 }
   
                 if (maxMembersSought == 0)
                     break;
             }
         }
         private static void SetTwoColumnFolderFirst(object pb, FieldInfo listAreaField)
         {
             if (listAreaField == null)
                 return;
             object listArea = listAreaField.GetValue(pb);
             // safety check
             if (listArea == null)
                 return;
             PropertyInfo folderFirst = listArea.GetType().GetProperties().Single(x => x.Name == k_FoldersFirstFieldName);
             folderFirst.SetValue(listArea, s_BoolTrue);
         }
         private static void SetOneColumnFolderFirst(object pb, FieldInfo assetTreeField)
         {
             if (assetTreeField == null)
                 return;
            
             object assetTree = assetTreeField.GetValue(pb);
             // Fix: as we are looping all members, it's possible to end up in a case where one member is seen first,
             // this will be null.
             if (assetTree == null)
                 return;
            
             PropertyInfo data = assetTree.GetType().GetRuntimeProperties().Single(x => x.Name == k_DataFieldName);
             // AssetsTreeViewDataSource
             object dataSource = data.GetValue(assetTree);
   
             // safety check
             if (dataSource == null)
                 return;
             PropertyInfo folderFirst = dataSource.GetType().GetProperties().Single(x => x.Name == k_FoldersFirstFieldName);
             folderFirst.SetValue(dataSource, s_BoolTrue);
         }
    }
3 Likes

This is amazing.

Also, this should be a standard feature.

The script above was breaking every time anything reloaded and you’d need to click something inside the project folder for it to sort.
So instead i just directly modified UnityEngine.CoreModule.dll using dnSpy:


(Applications/Unity/Hub/Editor/2021.3.9f1/Unity.app/Contents/Managed/UnityEngine/UnityEngine.CoreModule.dll)
(For 2022+ it’s UnityEditor.CoreModule.dll)
UnityEditor → ProjectBrowser → GetShouldShowFoldersFirst() → right click → Edit IL instructions → replace instructions with “ldc.i4.1”, “ret” (meaning ‘return true;’).
File > Save Module, replace the dll. Have windows style sorting forever without any scripts. Works with Unity 2021.3.9f1 for me.

//Before this I also tried a bunch of things like: Harmony to inject stuff into this method using a script (Harmony doesn’t work on M1 turns out), tried same with Mono.Cecil (turns out the core .dll is unreachable for modifications from there or something like that). dnSpy to the rescue, one less annoying thing about macOS.

7 Likes

Another cool tweak, since I’m not sure where to put it:

#if UNITY_EDITOR
using System.Reflection;
using UnityEditor;

[InitializeOnLoad]
public static class RemoveSelectAllOnMouseUp
{
    static FieldInfo field;
    
    static RemoveSelectAllOnMouseUp()
    {
        field = typeof(EditorGUI).GetField("s_SelectAllOnMouseUp", BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Default);
        EditorApplication.update += SetFalse;
    }
    
    private static void SetFalse()
    {
        field.SetValue(null, false);
    }
}
#endif

Use this script to disable the annoying behaviour of Inspector selecting all text when you click on a property, now it will save you that extra click of deselecting everything.

I was doing that before, but after update your editor it will broke and you need to do it again. >__<. So I though that fix that problem with click is more bearable than patch dll. Ahah

Loving the effort here boys cba but It’s good to know at least you can hack it in. Might give it a go but until then just going to add zeros to the start of my folders :stuck_out_tongue:

No idea why this is still not a feature in unity… being able to keep folders at the top of the project window seems like such a simple, must have feature.

The .dll hack is nice @JollyTheory , but sadly dnSpy is only available for Windows users, not mac or linux.

1 Like

+1

This should be a Unity feature by default for sure.

One can use the trial of parallels.com to run dnSpy on mac.

So not just me, this is an actual thing… that’s disappointing.

1 Like

In Unity 2022.3 this method is inside UnityEditor.CoreModule.dll instead of UnityEngine.CoreModule.dll. Works great, thank you for sharing.

2 Likes

I did this with CrossOver and it worked.

1 Like

How did you click it? I don’t see that option on my machine.