Add / Remove a folder jonction (symbolic link), inside unity


This devlog will show you how to have external folder inside your unity project. There are 2 main methods to have external folder:
  • Using symbolic links (can not be pushed in git)
  • Using jonctions (can be pushed in git)

We will see how to create jonctions.
This method work for now only in window, but it can be easly ported into different OS.
For the safety of your projects and files, when working with jonctions and symlink links, always have a backup, or a save in a git repository.




Add a jonction from Unity

Here you see how easy it is to create a jonction from unity, in 2 steps:

First, Right click on a directory in the project view, and select SymLink/Add SymLink

Then select the external directory you want to add in the Folder Panel


And that's it ! Jonctions are visualized with this little indicator <=> in the project view.
You can't have a jonction inside another jonction parent.




Remove a jonction from Unity, but keep local files

You can also remove the link and keep the local data present, in 2 steps:

First, Right click on a directory with a jonction in the project view, and select SymLink/Remove SymLink

Then you have a choose between two options:

- Remove only the Link (keep the local files)

- Remove the directory (same as using the suppr button).
Don't worry, when delete a jonction from unity, the actual extern directory will not be deleted, only the one inside unity




Restore a jonction

And last, you can restore the link of a local directory with an external one.
First, Right click on a directory with no jonction in the project view, and select SymLink/Restore SymLink

Then select the external directory you want to link in the Folder Panel
Caution, this option will override files if they exist in both directory. See carefully bellow the 2 options you have. And make sure to always have your files saved in git before doing this procedure.
When restoring a link from a local directory, the program will merge the two directory. If there a 2 files wifh the same name, you have 2 choices

- keep the local ones (safer for your project !)
- override with the external file (lose links)

If you choose to override from external file, you will loose links of files inside unity. (you know, the famous "references missing" because the lost/incorect GUID of meta files...) You may want to select the option "keep local ones".







Scripts

Simply Put these scripts inside an Editor/ Folder, and that's it !

ExtSymLink.cs
Download
Copy
´╗┐using UnityEditor;
using UnityEngine;
using System.IO;
using System.Diagnostics;
using System.Collections.Generic;

namespace symbolicLinks
{
    /// <summary>
    /// Add / Remove a folder jonction (symbolic link), inside unity
    /// A Folder jonction can be pushed in git, unlike a simple symbolic link.
    /// See menu items under "Assets/SymLink/..." when you do a right click inside project view
    /// draws a small indicator "<=>" in the Project view for folders that are symlinks.
    /// </summary>
    [InitializeOnLoad]
    public static class ExtSymLink
    {
        // FileAttributes that match a junction folder.
        private const FileAttributes FOLDER_SYMLINK_ATTRIBS = FileAttributes.Directory | FileAttributes.ReparsePoint;

        // Style used to draw the symlink indicator in the project view.
        private static GUIStyle _symlinkMarkerStyle = null;
        private static GUIStyle GetSymlinkMarkerStyle
        {
            get
            {
                if (_symlinkMarkerStyle == null)
                {
                    _symlinkMarkerStyle = new GUIStyle(EditorStyles.label);
                    _symlinkMarkerStyle.normal.textColor = Color.red;
                    _symlinkMarkerStyle.alignment = TextAnchor.MiddleRight;
                }
                return _symlinkMarkerStyle;
            }
        }

        /// <summary>
        /// Static constructor subscribes to projectWindowItemOnGUI delegate.
        /// </summary>
        static ExtSymLink()
        {
            EditorApplication.projectWindowItemOnGUI += OnProjectWindowItemGUI;
        }

        /// <summary>
        /// Draw an indicator if folder is a symlink
        /// </summary>
        private static void OnProjectWindowItemGUI(string guid, Rect r)
        {
            try
            {
                string path = AssetDatabase.GUIDToAssetPath(guid);

                if (!string.IsNullOrEmpty(path))
                {
                    FileAttributes attribs = File.GetAttributes(path);

                    if ((attribs & FOLDER_SYMLINK_ATTRIBS) == FOLDER_SYMLINK_ATTRIBS)
                    {
                        GUI.Label(r, "<=>", GetSymlinkMarkerStyle);
                    }
                }
            }
            catch { }
        }

        /// <summary>
        /// add a folder link at the current selection in the project view
        /// </summary>
        [MenuItem("Assets/SymLink/Add Folder Link", false, 20)]
        static void DoTheSymlink()
        {
            string targetPath = ExtFileEditor.GetSelectedPathOrFallback();

            FileAttributes attribs = File.GetAttributes(targetPath);

            if ((attribs & FileAttributes.Directory) != FileAttributes.Directory)
                targetPath = Path.GetDirectoryName(targetPath);

            if (IsSymLinkFolder(targetPath))
            {
                throw new System.Exception("directory is already a symLink");
            }
            if (IsFolderHasParentSymLink(targetPath, out string pathLinkFound))
            {
                throw new System.Exception("parent " + pathLinkFound + " is a symLink, doen't allow recursive symlinks");
            }

            OpenFolderPanel(out string sourceFolderPath, out string sourceFolderName);

            targetPath = targetPath + "/" + sourceFolderName;

            if (Directory.Exists(targetPath))
            {
                UnityEngine.Debug.LogWarning(string.Format("A folder already exists at this location, aborting link.\n{0} -> {1}", sourceFolderPath, targetPath));
                return;
            }


            string commandeLine = "/C mklink /J \"" + targetPath + "\" \"" + sourceFolderPath + "\"";
            CommandsLine.Execute(commandeLine);

            AssetDatabase.Refresh(ImportAssetOptions.ForceUpdate);
        }

        /// <summary>
        /// Restore a lost link
        /// WARNING: it will override identical files
        /// </summary>
        [MenuItem("Assets/SymLink/Restore Folder Link", false, 20)]
        private static void RestoreSymLink()
        {
            string targetPath = ExtFileEditor.GetSelectedPathOrFallback();
            string directoryName = Path.GetFileName(targetPath);

            FileAttributes attribs = File.GetAttributes(targetPath);

            if ((attribs & FileAttributes.Directory) != FileAttributes.Directory)
            {
                targetPath = Path.GetDirectoryName(targetPath);
            }

            if (IsSymLinkFolder(targetPath))
            {
                throw new System.Exception("directory " + directoryName + " is already a symLinkFolder");
            }

            OpenFolderPanel(out string sourceFolderPath, out string sourceFolderName);

            if (string.IsNullOrEmpty(sourceFolderName))
            {
                return;
            }

            if (directoryName != sourceFolderName)
            {
                throw new System.Exception("source and target have different names");
            }

            int choice = EditorUtility.DisplayDialogComplex("Restore SymLink", "If 2 files have the same names," +
                "which one do you want to keep ?", "Keep Local ones /!\\", "cancel procedure", "Override with new ones /!\\");

            if (choice == 1)
            {
                return;
            }

            try
            {
                //Place the Asset Database in a state where
                //importing is suspended for most APIs
                AssetDatabase.StartAssetEditing();

                ExtFileEditor.DuplicateDirectory(targetPath, out string newPathCreated);
                ExtFileEditor.DeleteDirectory(targetPath);
                string commandeLine = "/C mklink /J \"" + targetPath + "\" \"" + sourceFolderPath + "\"";
                CommandsLine.Execute(commandeLine);
                ExtFileEditor.MergeFolders(new DirectoryInfo(newPathCreated), new DirectoryInfo(targetPath), choice == 2);
                ExtFileEditor.DeleteDirectory(newPathCreated);
            }
            finally
            {
                //By adding a call to StopAssetEditing inside
                //a "finally" block, we ensure the AssetDatabase
                //state will be reset when leaving this function
                AssetDatabase.StopAssetEditing();
            }

            AssetDatabase.Refresh(ImportAssetOptions.ForceUpdate);
        }

        [MenuItem("Assets/SymLink/Remove Folder Link", false, 20)]
        private static void RemoveSymLink()
        {
            string targetPath = ExtFileEditor.GetSelectedPathOrFallback();
            string directoryName = Path.GetFileName(targetPath);

            FileAttributes attribs = File.GetAttributes(targetPath);

            if ((attribs & FileAttributes.Directory) != FileAttributes.Directory)
            {
                targetPath = Path.GetDirectoryName(targetPath);
            }

            if (!IsSymLinkFolder(targetPath))
            {
                throw new System.Exception("directory " + directoryName + " is not a symLinkFolder");
            }

            int choice = EditorUtility.DisplayDialogComplex("Remove SymLink", "Do you want to Remove the link only ?", "Remove Link Only", "Cancel", "Remove Link and Directory /!\\");
            if (choice == 1)
            {
                return;
            }
            string commandeLine = "/C rmdir \"" + ExtPaths.ReformatPathForWindow(targetPath) + "\"";

            if (choice == 2)
            {
                CommandsLine.Execute(commandeLine);
            }
            else
            {
                try
                {
                    //Place the Asset Database in a state where
                    //importing is suspended for most APIs
                    AssetDatabase.StartAssetEditing();

                    ExtFileEditor.DuplicateDirectory(targetPath, out string newPathCreated);
                    CommandsLine.Execute(commandeLine);
                    ExtFileEditor.RenameDirectory(newPathCreated, directoryName, false);
                }
                finally
                {
                    //By adding a call to StopAssetEditing inside
                    //a "finally" block, we ensure the AssetDatabase
                    //state will be reset when leaving this function
                    AssetDatabase.StopAssetEditing();
                }
            }
            AssetDatabase.Refresh(ImportAssetOptions.ForceUpdate);
        }

        /// <summary>
        /// open the folder panel, wait on output the folder path & folder name
        /// </summary>
        /// <param name="sourceFolderPath">path of the folder selected</param>
        /// <param name="sourceFolderName">name of the folder selected</param>
        /// <returns>false if abort</returns>
        private static bool OpenFolderPanel(out string sourceFolderPath, out string sourceFolderName)
        {
            sourceFolderPath = EditorUtility.OpenFolderPanel("Select Folder Source", "", "");
            sourceFolderName = "";

            if (sourceFolderPath.Contains(Application.dataPath))
            {
                throw new System.Exception("Cannot create a symlink to folder in your project!");
            }

            // Cancelled dialog
            if (string.IsNullOrEmpty(sourceFolderPath))
            {
                return (false);
            }

            sourceFolderName = Path.GetFileName(sourceFolderPath);

            if (string.IsNullOrEmpty(sourceFolderName))
            {
                UnityEngine.Debug.LogWarning("Couldn't deduce the folder name?");
                return (false);
            }
            return (true);
        }

        /// <summary>
        /// determine if the current selected folder has a parent (or itself), with
        /// a symLink. If it is, return true
        /// </summary>
        /// <param name="pathFolder">Assets/my/path/myFolderToTest</param>
        /// <param name="pathSymLinkFound">return the path of the symLink parent found (return "" if not)</param>
        /// <returns>true if found one parent with symLink</returns>
        public static bool IsFolderHasParentSymLink(string pathFolder, out string pathSymLinkFound)
        {
            pathSymLinkFound = "";

            //run though every parent directory, at a point it will return ""
            while (!string.IsNullOrEmpty(pathFolder))
            {
                string directoryName = Path.GetDirectoryName(pathFolder);

                if (IsSymLinkFolder(directoryName))
                {
                    pathSymLinkFound = directoryName;
                    return (true);
                }
                pathFolder = directoryName;
            }

            return (false);
        }

        /// <summary>
        /// Determine if the current folder path is a symLink or not
        /// </summary>
        /// <param name="targetPath">Assets/my/path/myFolder</param>
        /// <returns>true if it's a symLink</returns>
        public static bool IsSymLinkFolder(string targetPath)
        {
            if (string.IsNullOrEmpty(targetPath))
            {
                return (false);
            }

            FileAttributes attribs = File.GetAttributes(targetPath);
            bool isSymLink = (attribs & FOLDER_SYMLINK_ATTRIBS) == FOLDER_SYMLINK_ATTRIBS;
            return (isSymLink);
        }

        //end of class
    }

    public static class CommandsLine
    {
        /// <summary>
        /// Execute a commandLine in Window, mac or linux
        /// </summary>
        /// <param name="commandeLine"></param>
        public static void Execute(string commandeLine)
        {
#if UNITY_EDITOR_WIN
            Process cmd = Process.Start("CMD.exe", commandeLine);
            cmd.WaitForExit();
#elif UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX
			// @todo
#endif
        }
    }

    public static class ExtPaths
    {
        /// <summary>
        /// change a path from
        /// Assets\path\of\file
        /// to
        /// Assets/path/of/file
        /// </summary>
        /// <param name="path"></param>
        /// <returns></returns>
        public static string ReformatPathForUnity(string path, char characterReplacer = '-')
        {
            string formattedPath = path.Replace('\\', '/');
            formattedPath = formattedPath.Replace('|', characterReplacer);
            return (formattedPath);
        }

        /// <summary>
        /// change a path from
        /// Assets/path/of/file
        /// to
        /// Assets\path\of\file
        /// </summary>
        /// <param name="path"></param>
        /// <returns></returns>
        public static string ReformatPathForWindow(string path)
        {
            string formattedPath = path.Replace('/', '\\');
            formattedPath = formattedPath.Replace('|', '-');
            return (formattedPath);
        }

        /// <summary>
        /// take a path of a file, increment his number in his name, test if it exist, and return the new path
        /// 
        /// use: string newFilePath = ExtString.RenameIncrementalFile(AssetDatabase.GetAssetPath(assetObject));
        /// if you want to have only the name after, do:
        /// string only name = Path.GetFileName(newFilePath);
        /// 
        /// return the renamed file asset like:
        /// Asset/Folder/myFileAsset.asset      ->  Asset/Folder/myFileAsset 1.asset
        /// Asset/Folder/myFileAsset 0.asset    ->  Asset/Folder/myFileAsset 1.asset
        /// Asset/Folder/myFileAsset 58.asset    ->  Asset/Folder/myFileAsset 59.asset
        /// Asset/Folder/myFileAsset58.asset    ->  Asset/Folder/myFileAsset 59.asset
        /// asset 24.asset
        /// </summary>
        /// <param name="pathFileToRename">path of the file</param>
        /// <param name="index">new index calculated</param>
        /// <returns></returns>
        public static string RenameIncrementalFile(string pathFileToRename, out int index, bool setZeroIfNothingFound)
        {
            string nameAsset = Path.GetFileNameWithoutExtension(pathFileToRename);
            int numberPrevious = ExtString.ExtractIntFromEndOfString(nameAsset);

            string nameWithoutNumber = nameAsset.Replace(numberPrevious.ToString(), "");
            nameWithoutNumber = nameWithoutNumber.TrimEnd(' ');

            string dirName = Path.GetDirectoryName(pathFileToRename).Replace('\\', '/') + '/';

            string newName = "";
            string finalRenamedPath = "";
            index = numberPrevious;

            do
            {
                if (index == 0)
                {
                    if (setZeroIfNothingFound)
                    {
                        newName = nameWithoutNumber + " " + (index);
                    }
                    else
                    {
                        newName = nameWithoutNumber;
                    }
                }
                else
                {
                    newName = nameWithoutNumber + " " + (index);
                }
                finalRenamedPath = dirName + newName + Path.GetExtension(pathFileToRename);
                index++;
            }
            while (File.Exists(finalRenamedPath) || Directory.Exists(finalRenamedPath));
            return (finalRenamedPath);
        }
    }

    public static class ExtString
    {
        /// <summary>
        /// from a given string, like: RaceTrack 102
        /// extract the number 102 (int)
        /// </summary>
        /// <param name="input"></param>
        /// <returns>number in the end of string</returns>
        public static int ExtractIntFromEndOfString(string input)
        {
            var stack = new Stack<char>();
            for (var i = input.Length - 1; i >= 0; i--)
            {
                if (!char.IsNumber(input[i]))
                {
                    break;
                }

                stack.Push(input[i]);
            }

            string result = new string(stack.ToArray());
            return (result.ToInt());
        }

        /// <summary>
        /// Converts a string to an int
        /// </summary>
        /// <param name="value">value to convert</param>
        /// <param name="defaultValue">default value if could not convert</param>
        public static int ToInt(this string value, int defaultValue = 0)
        {
            // exit if null
            if (string.IsNullOrEmpty(value))
                return defaultValue;

            // convert
            int rVal;
            return int.TryParse(value, out rVal) ? rVal : defaultValue;
        }
    }

    public static class ExtFileEditor
    {
        /// <summary>
        /// return the path of the current selected folder (or the current folder of the asset selected)
        /// </summary>
        /// <returns></returns>
        public static string GetSelectedPathOrFallback()
        {
            string path = "Assets";

            foreach (UnityEngine.Object obj in Selection.GetFiltered(typeof(UnityEngine.Object), SelectionMode.Assets))
            {
                path = AssetDatabase.GetAssetPath(obj);
                if (!string.IsNullOrEmpty(path) && File.Exists(path))
                {
                    path = Path.GetDirectoryName(path);
                    break;
                }
            }
            return (ExtPaths.ReformatPathForUnity(path));
        }

        /// <summary>
        /// duplicate a directory at the same location, with an incremental name
        /// </summary>
        /// <param name="pathDirectoryToDuplicate"></param>
        /// <returns></returns>
        public static bool DuplicateDirectory(string pathDirectoryToDuplicate, out string newPathDirectory)
        {
            newPathDirectory = ExtPaths.RenameIncrementalFile(pathDirectoryToDuplicate, out int index, false);
            return (ExtFileEditor.DuplicateDirectory(pathDirectoryToDuplicate, newPathDirectory));
        }

        /// <summary>
        /// duplicate a directory
        /// from Assets/to/duplicate
        /// to Assets/new/location
        /// </summary>
        /// <param name="pathDirectoryToDuplicate">directory path to duplicate</param>
        /// <param name="newPathDirectory">new direcotry path</param>
        /// <param name="incrementDirectoryNameIfNeeded">do we increment the new duplicated name of the folder if folder already exist ?</param>
        /// <returns>true if succed, false if not duplicated</returns>
        public static bool DuplicateDirectory(string pathDirectoryToDuplicate, string newPathDirectory, bool incrementDirectoryNameIfNeeded = true, bool refreshDataBase = true)
        {
            if (ExtFile.IsPathIsDirectory(newPathDirectory))
            {
                if (incrementDirectoryNameIfNeeded)
                {
                    newPathDirectory = ExtPaths.RenameIncrementalFile(newPathDirectory, out int index, false);
                }
                else
                {
                    //directory exist
                    return (false);
                }
            }
            FileUtil.CopyFileOrDirectory(pathDirectoryToDuplicate, newPathDirectory);

            if (refreshDataBase)
            {
                AssetDatabase.Refresh();
            }
            return (true);
        }

        /// <summary>
        /// from 2 folder path, merge them together
        /// </summary>
        /// <param name="sourcePath">path of the first folder</param>
        /// <param name="sourceTarget">path of the second folder</param>
        /// <param name="overrideFile">do we override files it already exist ?</param>
        public static void MergeFolders(string sourcePath, string sourceTarget, bool overrideFile)
        {
            ExtFileEditor.MergeFolders(new DirectoryInfo(sourcePath), new DirectoryInfo(sourceTarget), overrideFile);
        }

        /// <summary>
        /// from 2 folder path, merge them together
        /// </summary>
        /// <param name="sourcePath">path of the first folder</param>
        /// <param name="sourceTarget">path of the second folder</param>
        /// <param name="overrideFile">do we override files it already exist ?</param>
        public static void MergeFolders(DirectoryInfo source, DirectoryInfo target, bool overrideFile)
        {
            if (source.FullName.ToLower() == target.FullName.ToLower())
            {
                return;
            }

            // Check if the target directory exists, if not, create it.
            if (Directory.Exists(target.FullName) == false)
            {
                Directory.CreateDirectory(target.FullName);
            }

            // Copy each file into it's new directory.
            foreach (FileInfo fi in source.GetFiles())
            {
                //Debug.Log("Copying " + target.FullName + " / " + fi.Name);
                string destinationPath = Path.Combine(target.ToString(), fi.Name);
                destinationPath = ExtPaths.ReformatPathForWindow(destinationPath);
                if (ExtFile.IsFileExist(destinationPath) && overrideFile)
                {
                    //file exist ! don't override this one
                    continue;
                }
                fi.CopyTo(destinationPath, true);
            }

            // Copy each subdirectory using recursion.
            foreach (DirectoryInfo diSourceSubDir in source.GetDirectories())
            {
                DirectoryInfo nextTargetSubDir =
                    target.CreateSubdirectory(diSourceSubDir.Name);
                MergeFolders(diSourceSubDir, nextTargetSubDir, overrideFile);
            }
        }

        /// <summary>
        /// delete a directory and it's childen
        /// </summary>
        /// <param name="pathOfDirectory"></param>
        /// <returns></returns>
        public static bool DeleteDirectory(string pathOfDirectory)
        {
            return (FileUtil.DeleteFileOrDirectory(pathOfDirectory));
        }

        /// <summary>
        /// delete a directory and it's childen
        /// </summary>
        /// <param name="pathOfDirectory"></param>
        /// <returns></returns>
        public static bool DeleteFile(string pathOfDirectory)
        {
            return (FileUtil.DeleteFileOrDirectory(pathOfDirectory));
        }

        /// <summary>
        /// rename an asset, and return the name
        /// </summary>
        /// <param name="oldPath"></param>
        /// <param name="newName"></param>
        /// <returns></returns>
        public static string RenameDirectory(string oldPath, string newName, bool refreshAsset)
        {
            if (ExtFile.IsFileExist(oldPath))
            {
                throw new System.Exception("a directory must be given, not a file");
            }

            string pathWhitoutName = Path.GetDirectoryName(oldPath);
            pathWhitoutName = ExtPaths.ReformatPathForUnity(pathWhitoutName);
            string newWantedName = ExtPaths.ReformatPathForUnity(newName);

            FileUtil.MoveFileOrDirectory(oldPath, pathWhitoutName + "/" + newWantedName);
            if (refreshAsset)
            {
                AssetDatabase.Refresh();
            }
            string newPath = pathWhitoutName + "/" + newWantedName;
            // oldPath renamed to newPath
            return (newPath);
        }

        //end class
    }

    public static class ExtFile
    {
        public static bool IsPathIsDirectory(string path)
        {
            return (Directory.Exists(path));
        }

        public static bool IsFileExist(string path)
        {
            return (File.Exists(path));
        }
        //end of class
    }

    //end of nameSpace
}









See also:

Animator Extension: How to use advanced reflection step by step

Auto-play from the scene view the first animation of an Animator, without any additionnal script on gameObjects !


Switch Camera Projection

Swap the Projection camera from Perspective to Orthographic using a linear interpolation function coupled with an easing function