using System; using System.Collections.Generic; using System.IO; using System.Linq; using AlicizaX.UXTool; using UnityEditor; using UnityEngine; using UnityEngine.UIElements; public class PrefabTreeViewWindow : EditorWindow { private const string EditorPrefKeyPrefix = "PrefabTreeView_Expanded_"; // key + Root private ScrollView treeScroll; private TextField searchField; private Texture2D folderIcon; private Texture2D folderEmptyIcon; private Texture2D defaultPrefabIcon; private Image previewIcon; private Label previewLabel; private HashSet expandedPaths = new HashSet(StringComparer.InvariantCultureIgnoreCase); private HashSet expandedSnapshot = null; private HashSet selectedPaths = new HashSet(StringComparer.InvariantCultureIgnoreCase); private string searchText = string.Empty; private string currentDragTargetPath = null; private VisualElement currentDragTargetElement = null; [MenuItem("Window/Prefab Tree View (Fixed Root)")] public static void ShowWindow() { var wnd = GetWindow(); wnd.titleContent = new GUIContent("Prefab Tree View"); wnd.minSize = new Vector2(420, 300); } private void OnEnable() { folderIcon = EditorGUIUtility.IconContent("Folder Icon").image as Texture2D; if (folderIcon == null) folderIcon = EditorGUIUtility.IconContent("Folder").image as Texture2D; folderEmptyIcon = EditorGUIUtility.IconContent("FolderEmpty Icon").image as Texture2D; if (folderEmptyIcon == null) folderEmptyIcon = EditorGUIUtility.IconContent("FolderEmpty").image as Texture2D; if (folderEmptyIcon == null) folderEmptyIcon = folderIcon; // fallback defaultPrefabIcon = EditorGUIUtility.IconContent("Prefab Icon").image as Texture2D; if (defaultPrefabIcon == null) defaultPrefabIcon = EditorGUIUtility.IconContent("PrefabNormal Icon").image as Texture2D; rootVisualElement.Clear(); BuildUI(); LoadExpandedState(); RefreshTree(); rootVisualElement.RegisterCallback(OnKeyDown); rootVisualElement.focusable = true; rootVisualElement.RegisterCallback(evt => { rootVisualElement.Focus(); }); } private void OnDisable() { SaveExpandedState(); } private VisualElement previewContainer; private void BuildUI() { var root = rootVisualElement; var split = new UnityEngine.UIElements.TwoPaneSplitView(1, 50, TwoPaneSplitViewOrientation.Vertical); split.style.flexGrow = 1; var topContainer = new VisualElement(); topContainer.name = "TopContainer"; topContainer.style.flexDirection = FlexDirection.Column; topContainer.style.flexGrow = 1; var searchRow = new VisualElement(); searchRow.style.flexDirection = FlexDirection.Row; searchRow.style.alignItems = Align.Center; searchRow.style.marginBottom = 4; searchField = new TextField(); searchField.style.flexGrow = 1; searchField.RegisterCallback>(evt => OnSearchChanged(evt.newValue)); var clearBtn = new Button(() => { searchField.value = string.Empty; OnSearchChanged(string.Empty); }) { text = "Clear" }; var refreshBtn = new Button(() => RefreshTree()); refreshBtn.style.backgroundImage = new StyleBackground(EditorGUIUtility.IconContent("d_Refresh").image as Texture2D); refreshBtn.style.height = 20; refreshBtn.style.width = 20; refreshBtn.style.backgroundSize = new BackgroundSize(new Length(80, LengthUnit.Percent), new Length(80, LengthUnit.Percent)); var createFolder = new Button(() => { CreateFolderUnder(Def_UXGUIPath.UIResRootPath); }); createFolder.style.backgroundImage = new StyleBackground(EditorGUIUtility.IconContent("Folder Icon").image as Texture2D); createFolder.style.height = 20; createFolder.style.width = 20; createFolder.style.backgroundSize = new BackgroundSize(new Length(80, LengthUnit.Percent), new Length(80, LengthUnit.Percent)); searchRow.Add(searchField); searchRow.Add(refreshBtn); searchRow.Add(createFolder); topContainer.Add(searchRow); treeScroll = new ScrollView(); treeScroll.style.flexGrow = 1; // treeScroll.verticalScrollerVisibility = ScrollerVisibility.Hidden; treeScroll.horizontalScrollerVisibility = ScrollerVisibility.Hidden; treeScroll.pickingMode = PickingMode.Position; treeScroll.RegisterCallback(evt => { OnDragEnterOrUpdate(evt.target as VisualElement); evt.StopPropagation(); }); treeScroll.RegisterCallback(evt => { OnDragEnterOrUpdate(evt.target as VisualElement); evt.StopPropagation(); }); treeScroll.RegisterCallback(evt => { OnDragPerform(evt); evt.StopPropagation(); }); treeScroll.RegisterCallback(evt => { ClearDragTarget(); evt.StopPropagation(); }); treeScroll.RegisterCallback(evt => { ClearDragTarget(); }); treeScroll.AddManipulator(new ContextualMenuManipulator(evt => { var t = evt.target as VisualElement; bool hasPathAncestor = false; while (t != null) { if (t.userData is string) { hasPathAncestor = true; break; } t = t.parent as VisualElement; } if (!hasPathAncestor) { evt.menu.AppendAction("新建文件夹", a => CreateFolderUnder(Def_UXGUIPath.UIResRootPath), a => DropdownMenuAction.Status.Normal); evt.menu.AppendAction("新建组件", a => CreatePrefabUnder(Def_UXGUIPath.UIResRootPath), a => DropdownMenuAction.Status.Normal); if (selectedPaths.Count > 0) { evt.menu.AppendSeparator(); evt.menu.AppendAction($"删除选中 ({selectedPaths.Count})", a => DeleteSelectionWithConfirm(), a => DropdownMenuAction.Status.Normal); } } })); topContainer.Add(treeScroll); previewContainer = new VisualElement(); previewContainer.name = "PreviewContainer"; previewContainer.style.flexGrow = 1; previewContainer.style.minHeight = 180; previewContainer.style.backgroundColor = new StyleColor(new Color(0.2f, 0.2f, 0.2f)); previewLabel = new Label("Preview"); previewLabel.name = "previewLabel"; previewLabel.style.unityFontStyleAndWeight = FontStyle.Bold; previewLabel.style.height = 24; previewLabel.style.unityTextAlign = TextAnchor.MiddleCenter; previewContainer.Add(previewLabel); previewIcon = new Image(); previewIcon.style.height = Length.Percent(100); previewIcon.style.width = Length.Percent(100); previewContainer.Add(previewIcon); split.Add(topContainer); split.Add(previewContainer); root.Add(split); root.RegisterCallback(evt => { if (evt.button != 0) return; var target = evt.target as VisualElement; bool hasPathAncestor = false; while (target != null) { if (target.userData is string) { hasPathAncestor = true; break; } target = target.parent as VisualElement; } if (!hasPathAncestor) ClearSelection(); }, TrickleDown.TrickleDown); } private void OnSearchChanged(string newValue) { var trimmed = (newValue ?? string.Empty).Trim(); bool wasEmpty = string.IsNullOrEmpty(searchText); bool nowEmpty = string.IsNullOrEmpty(trimmed); if (wasEmpty && !nowEmpty) { expandedSnapshot = new HashSet(expandedPaths, StringComparer.InvariantCultureIgnoreCase); } if (!wasEmpty && nowEmpty) { if (expandedSnapshot != null) { expandedPaths = new HashSet(expandedSnapshot, StringComparer.InvariantCultureIgnoreCase); expandedSnapshot = null; } } searchText = trimmed; RefreshTree(); } private void RefreshTree() { treeScroll.Clear(); selectedPaths.Clear(); Selection.objects = new UnityEngine.Object[0]; ClearDragTarget(); if (!AssetDatabase.IsValidFolder(Def_UXGUIPath.UIResRootPath)) { treeScroll.Add(new Label($"Invalid root path: {Def_UXGUIPath.UIResRootPath}. Please create it in Project window.")); return; } var subfolders = AssetDatabase.GetSubFolders(Def_UXGUIPath.UIResRootPath); Array.Sort(subfolders, StringComparer.InvariantCultureIgnoreCase); foreach (var sf in subfolders) { if (string.IsNullOrEmpty(searchText) || FolderMatchesSearchRecursive(sf)) { var item = CreateFolderItem(sf, 0, inSearch: !string.IsNullOrEmpty(searchText)); if (item != null) treeScroll.Add(item); } } } private bool FolderMatchesSearchRecursive(string folderPath) { if (string.IsNullOrEmpty(searchText)) return true; var key = searchText.ToLowerInvariant(); if (Path.GetFileName(folderPath).ToLowerInvariant().Contains(key)) return true; var prefabGuids = AssetDatabase.FindAssets("t:GameObject", new[] { folderPath }); foreach (var g in prefabGuids) { var p = AssetDatabase.GUIDToAssetPath(g); if (Path.GetFileName(p).ToLowerInvariant().Contains(key)) return true; } var subs = AssetDatabase.GetSubFolders(folderPath); foreach (var s in subs) { if (FolderMatchesSearchRecursive(s)) return true; } return false; } private VisualElement CreateFolderItem(string folderAssetPath, int indentLevel, bool inSearch = false) { var container = new VisualElement(); container.style.flexDirection = FlexDirection.Column; container.style.alignSelf = Align.Stretch; var header = new VisualElement(); header.style.flexDirection = FlexDirection.Row; header.style.alignItems = Align.Center; header.style.paddingLeft = 2 + indentLevel * 12; header.style.paddingTop = 2; header.style.paddingBottom = 2; header.style.marginLeft = indentLevel > 0 ? -18 : 0; header.pickingMode = PickingMode.Position; bool hasChildren = FolderHasAnyDirectChild(folderAssetPath); bool isExpanded = expandedPaths.Contains(folderAssetPath); var chevron = new Label(hasChildren ? (isExpanded ? "▼" : "▶") : ""); chevron.style.unityTextAlign = TextAnchor.MiddleLeft; chevron.style.width = 12; chevron.style.marginRight = 6; chevron.style.fontSize = 8; var iconImg = new UnityEngine.UIElements.Image(); iconImg.image = hasChildren ? folderIcon : folderEmptyIcon; iconImg.style.width = 16; iconImg.style.height = 16; iconImg.style.marginRight = 6; var nameLabel = new Label(Path.GetFileName(folderAssetPath)); nameLabel.style.unityTextAlign = TextAnchor.MiddleLeft; header.Add(chevron); header.Add(iconImg); header.Add(nameLabel); header.userData = folderAssetPath; var childContainer = new VisualElement(); childContainer.style.flexDirection = FlexDirection.Column; childContainer.style.marginLeft = 12; bool displayExpanded = isExpanded; if (inSearch && !string.IsNullOrEmpty(searchText)) { if (FolderMatchesSearchRecursive(folderAssetPath)) { var selfMatches = Path.GetFileName(folderAssetPath).ToLowerInvariant().Contains(searchText.ToLowerInvariant()); if (!selfMatches) displayExpanded = true; } else { return null; } } childContainer.style.display = displayExpanded ? DisplayStyle.Flex : DisplayStyle.None; if (displayExpanded && childContainer.childCount == 0) { PopulateFolderChildren(folderAssetPath, childContainer, indentLevel + 1, inSearch); } chevron.RegisterCallback(evt => { if (hasChildren && string.IsNullOrEmpty(searchText)) { bool willExpand = childContainer.style.display == DisplayStyle.None; SetExpanded(chevron, childContainer, willExpand); if (willExpand && childContainer.childCount == 0) { PopulateFolderChildren(folderAssetPath, childContainer, indentLevel + 1, inSearch); } if (willExpand) expandedPaths.Add(folderAssetPath); else expandedPaths.Remove(folderAssetPath); SaveExpandedState(); } HandleSelectionClick(folderAssetPath, evt); evt.StopImmediatePropagation(); }); header.RegisterCallback(evt => { if (evt.button != 0) return; HandleSelectionClick(folderAssetPath, evt); var mouseDownPos = evt.mousePosition; EventCallback moveCb = null; EventCallback upCb = null; List dragPaths = new List(); if (selectedPaths.Contains(folderAssetPath)) dragPaths = selectedPaths.ToList(); else dragPaths.Add(folderAssetPath); moveCb = (MouseMoveEvent me) => { if ((me.mousePosition - mouseDownPos).sqrMagnitude > 16f) { StartDragAssets(dragPaths); header.UnregisterCallback(moveCb); header.UnregisterCallback(upCb); } }; upCb = (MouseUpEvent mu) => { header.UnregisterCallback(moveCb); header.UnregisterCallback(upCb); }; header.RegisterCallback(moveCb); header.RegisterCallback(upCb); evt.StopImmediatePropagation(); }); header.AddManipulator(new ContextualMenuManipulator(evt => { evt.menu.ClearItems(); if (selectedPaths.Count == 1 && selectedPaths.Contains(folderAssetPath)) { evt.menu.AppendAction("新建文件夹", a => CreateFolderUnder(folderAssetPath), a => DropdownMenuAction.Status.Normal); evt.menu.AppendAction("新建组件", a => CreatePrefabUnder(folderAssetPath), a => DropdownMenuAction.Status.Normal); evt.menu.AppendSeparator(); evt.menu.AppendAction("删除文件夹", a => DeleteSingleFolderWithConfirm(folderAssetPath), a => DropdownMenuAction.Status.Normal); } else if (selectedPaths.Count > 0) { evt.menu.AppendAction($"删除选中 ({selectedPaths.Count})", a => DeleteSelectionWithConfirm(), a => DropdownMenuAction.Status.Normal); } else { evt.menu.AppendAction("新建文件夹", a => CreateFolderUnder(folderAssetPath), a => DropdownMenuAction.Status.Normal); evt.menu.AppendAction("新建组件", a => CreatePrefabUnder(folderAssetPath), a => DropdownMenuAction.Status.Normal); evt.menu.AppendSeparator(); evt.menu.AppendAction("删除文件夹", a => DeleteSingleFolderWithConfirm(folderAssetPath), a => DropdownMenuAction.Status.Normal); } })); header.RegisterCallback(evt => { OnDragEnterOrUpdate(evt.target as VisualElement); evt.StopPropagation(); }); header.RegisterCallback(evt => { OnDragEnterOrUpdate(evt.target as VisualElement); evt.StopPropagation(); }); header.RegisterCallback(evt => { OnDragPerform(evt); evt.StopPropagation(); }); header.RegisterCallback(evt => { ClearDragTarget(); evt.StopPropagation(); }); container.Add(header); container.Add(childContainer); return container; } private void PopulateFolderChildren(string folderAssetPath, VisualElement childContainer, int indentLevel, bool inSearch) { var subfolders = AssetDatabase.GetSubFolders(folderAssetPath); Array.Sort(subfolders, StringComparer.InvariantCultureIgnoreCase); foreach (var sf in subfolders) { if (string.IsNullOrEmpty(searchText) || FolderMatchesSearchRecursive(sf)) { var el = CreateFolderItem(sf, indentLevel, inSearch); if (el != null) childContainer.Add(el); } } var prefabs = GetPrefabsInFolder(folderAssetPath); foreach (var p in prefabs) { if (!string.IsNullOrEmpty(searchText)) { if (!Path.GetFileName(p).ToLowerInvariant().Contains(searchText.ToLowerInvariant())) continue; } childContainer.Add(CreatePrefabRow(p, indentLevel)); } } private void SetExpanded(Label chevron, VisualElement childContainer, bool expand) { chevron.text = expand ? "▼" : "▶"; childContainer.style.display = expand ? DisplayStyle.Flex : DisplayStyle.None; } private bool FolderHasAnyDirectChild(string folderAssetPath) { var subs = AssetDatabase.GetSubFolders(folderAssetPath); if (subs != null && subs.Length > 0) return true; var guids = AssetDatabase.FindAssets("", new[] { folderAssetPath }); foreach (var g in guids) { var p = AssetDatabase.GUIDToAssetPath(g); if (string.IsNullOrEmpty(p)) continue; var parent = Path.GetDirectoryName(p).Replace("\\", "/"); if (string.Equals(parent, folderAssetPath, StringComparison.InvariantCultureIgnoreCase)) { if (p.EndsWith(".meta", StringComparison.InvariantCultureIgnoreCase)) continue; return true; } } return false; } private VisualElement CreatePrefabRow(string prefabAssetPath, int indentLevel) { var row = new VisualElement(); row.style.flexDirection = FlexDirection.Row; row.style.alignItems = Align.Center; row.style.paddingLeft = 2 + indentLevel * 12; row.style.paddingTop = 2; row.style.paddingBottom = 2; row.pickingMode = PickingMode.Position; Texture2D icon = AssetDatabase.GetCachedIcon(prefabAssetPath) as Texture2D; var mainObj = AssetDatabase.LoadMainAssetAtPath(prefabAssetPath); if (icon == null && mainObj != null) { var objContent = EditorGUIUtility.ObjectContent(mainObj, mainObj.GetType()); icon = objContent != null ? objContent.image as Texture2D : null; } if (icon == null) icon = defaultPrefabIcon; var iconImg = new UnityEngine.UIElements.Image(); iconImg.image = icon; iconImg.style.width = 16; iconImg.style.height = 16; iconImg.style.marginRight = 6; var lbl = new Label(Path.GetFileNameWithoutExtension(prefabAssetPath)); lbl.style.unityTextAlign = TextAnchor.MiddleLeft; row.Add(iconImg); row.Add(lbl); row.userData = prefabAssetPath; row.RegisterCallback(evt => { if (evt.button != 0) return; if (evt.clickCount == 2) { if (mainObj != null) { PrefabStageUtils.SwitchStage(prefabAssetPath); } evt.StopImmediatePropagation(); return; } HandleSelectionClick(prefabAssetPath, evt); var mouseDownPos = evt.mousePosition; EventCallback moveCb = null; EventCallback upCb = null; List dragPaths = new List(); if (selectedPaths.Contains(prefabAssetPath)) dragPaths = selectedPaths.ToList(); else dragPaths.Add(prefabAssetPath); moveCb = (MouseMoveEvent me) => { if ((me.mousePosition - mouseDownPos).sqrMagnitude > 16f) { StartDragAssets(dragPaths); row.UnregisterCallback(moveCb); row.UnregisterCallback(upCb); } }; upCb = (MouseUpEvent mu) => { row.UnregisterCallback(moveCb); row.UnregisterCallback(upCb); }; row.RegisterCallback(moveCb); row.RegisterCallback(upCb); evt.StopImmediatePropagation(); }); row.AddManipulator(new ContextualMenuManipulator(evt => { evt.menu.ClearItems(); if (selectedPaths.Count > 0) { evt.menu.AppendAction($"删除选中 ({selectedPaths.Count})", a => DeleteSelectionWithConfirm(), a => DropdownMenuAction.Status.Normal); } else { evt.menu.AppendAction("删除预制体", a => DeleteSinglePrefabWithConfirm(prefabAssetPath), a => DropdownMenuAction.Status.Normal); } })); row.RegisterCallback(evt => { OnDragEnterOrUpdate(evt.target as VisualElement); evt.StopPropagation(); }); row.RegisterCallback(evt => { OnDragEnterOrUpdate(evt.target as VisualElement); evt.StopPropagation(); }); row.RegisterCallback(evt => { OnDragPerform(evt); evt.StopPropagation(); }); row.RegisterCallback(evt => { ClearDragTarget(); evt.StopPropagation(); }); return row; } private string[] GetPrefabsInFolder(string folderAssetPath) { var guids = AssetDatabase.FindAssets("t:GameObject", new[] { folderAssetPath }); List result = new List(); foreach (var g in guids) { var p = AssetDatabase.GUIDToAssetPath(g); var parent = Path.GetDirectoryName(p).Replace("\\", "/"); if (string.Equals(parent, folderAssetPath, StringComparison.InvariantCultureIgnoreCase)) result.Add(p); } result.Sort(StringComparer.InvariantCultureIgnoreCase); return result.ToArray(); } private void HandleSelectionClick(string assetPath, MouseDownEvent evt) { bool isCtrl = evt.ctrlKey || evt.commandKey; if (isCtrl) { if (selectedPaths.Contains(assetPath)) selectedPaths.Remove(assetPath); else selectedPaths.Add(assetPath); } else { selectedPaths.Clear(); selectedPaths.Add(assetPath); } UpdateEditorSelection(); UpdateVisualSelectionMarkers(); } private void ClearSelection() { if (selectedPaths.Count == 0) return; selectedPaths.Clear(); UpdateEditorSelection(); UpdateVisualSelectionMarkers(); } private void UpdateEditorSelection() { var list = new List(); foreach (var p in selectedPaths) { var o = AssetDatabase.LoadMainAssetAtPath(p); if (o != null) list.Add(o); } Selection.objects = list.ToArray(); } private void UpdateVisualSelectionMarkers() { void Mark(VisualElement elem) { if (elem.userData is string path) { if (selectedPaths.Contains(path)) elem.style.backgroundColor = new StyleColor(new Color(0.24f, 0.48f, 0.90f, 0.25f)); else elem.style.backgroundColor = StyleKeyword.Null; } foreach (var c in elem.Children()) Mark(c); } foreach (var child in treeScroll.Children()) Mark(child); UpdatePreviewTexture(); } private void DeleteSelectionWithConfirm() { if (selectedPaths.Count == 0) return; string message = "Are you sure you want to delete the following assets?\n\n" + string.Join("\n", selectedPaths); if (!EditorUtility.DisplayDialog("Delete Selected", message, "Delete", "Cancel")) return; var prefabList = new List(); var folderList = new List(); foreach (var p in selectedPaths) { if (AssetDatabase.IsValidFolder(p)) folderList.Add(p); else prefabList.Add(p); } foreach (var p in prefabList) AssetDatabase.DeleteAsset(p); folderList.Sort((a, b) => b.Length.CompareTo(a.Length)); foreach (var f in folderList) AssetDatabase.DeleteAsset(f); AssetDatabase.Refresh(); selectedPaths.Clear(); RefreshTree(); } private void DeleteSinglePrefabWithConfirm(string prefabAssetPath) { if (!EditorUtility.DisplayDialog("Delete Prefab", $"Delete prefab:\n{prefabAssetPath}?", "Delete", "Cancel")) return; AssetDatabase.DeleteAsset(prefabAssetPath); AssetDatabase.Refresh(); RefreshTree(); } private void DeleteSingleFolderWithConfirm(string folderAssetPath) { if (!EditorUtility.DisplayDialog("Delete Folder", $"Delete folder and all contents:\n{folderAssetPath}?", "Delete", "Cancel")) return; AssetDatabase.DeleteAsset(folderAssetPath); AssetDatabase.Refresh(); RefreshTree(); } private void CreateFolderUnder(string parentAssetFolder) { if (!AssetDatabase.IsValidFolder(parentAssetFolder)) parentAssetFolder = Def_UXGUIPath.UIResRootPath; string baseName = "NewFolder"; string newName = baseName; int i = 1; while (AssetDatabase.IsValidFolder(PathCombine(parentAssetFolder, newName))) { newName = baseName + " " + i; i++; } AssetDatabase.CreateFolder(parentAssetFolder, newName); AssetDatabase.Refresh(); RefreshTree(); } private void CreatePrefabUnder(string parentAssetFolder) { if (!AssetDatabase.IsValidFolder(parentAssetFolder)) parentAssetFolder = Def_UXGUIPath.UIResRootPath; UXComponentCreateWindowHelper.ShowWindow(parentAssetFolder, RefreshTree); } private static string PathCombine(string a, string b) { if (a.EndsWith("/")) a = a.Substring(0, a.Length - 1); return a + "/" + b; } private string GetEditorPrefKey() { return EditorPrefKeyPrefix + Def_UXGUIPath.UIResRootPath; } private void SaveExpandedState() { try { var list = new List(expandedPaths); var joined = string.Join("|", list); EditorPrefs.SetString(GetEditorPrefKey(), joined); } catch (Exception e) { Debug.LogWarning("Failed to save expanded state: " + e.Message); } } private void LoadExpandedState() { expandedPaths.Clear(); try { var joined = EditorPrefs.GetString(GetEditorPrefKey(), ""); if (!string.IsNullOrEmpty(joined)) { var parts = joined.Split(new[] { '|' }, StringSplitOptions.RemoveEmptyEntries); foreach (var p in parts) expandedPaths.Add(p); } } catch (Exception e) { Debug.LogWarning("Failed to load expanded state: " + e.Message); } } private void OnKeyDown(KeyDownEvent evt) { if (evt.keyCode == KeyCode.Delete || evt.keyCode == KeyCode.Backspace) { DeleteSelectionWithConfirm(); evt.StopPropagation(); return; } if (evt.keyCode == KeyCode.F2) { if (selectedPaths.Count == 1) { var path = selectedPaths.First(); StartRename(path); evt.StopPropagation(); } } } private Texture texture; private void UpdatePreviewTexture() { if (selectedPaths.Count == 0 || selectedPaths.Count > 1 || (selectedPaths.Count > 0 && !selectedPaths.First().Contains(".prefab"))) { this.texture = null; previewIcon.image = null; previewLabel.text = string.Empty; return; } string previewPath = selectedPaths.First(); string guid = AssetDatabase.AssetPathToGUID(previewPath); texture = Utils.GetAssetsPreviewTexture(guid, 1024); previewLabel.text = Path.GetFileNameWithoutExtension(previewPath) + " (preview)"; previewIcon.image = texture; } private void StartDragAssets(List assetPaths) { var objs = new List(); foreach (var p in assetPaths) { var o = AssetDatabase.LoadMainAssetAtPath(p); if (o != null) objs.Add(o); } DragAndDrop.PrepareStartDrag(); DragAndDrop.paths = assetPaths.ToArray(); DragAndDrop.objectReferences = objs.ToArray(); DragAndDrop.StartDrag("Move Assets"); } /// /// Called by DragEnter / DragUpdated - determine destination and update visuals. /// private void OnDragEnterOrUpdate(VisualElement evtTarget) { var destFolder = GetDropDestinationFromVisualTarget(evtTarget); bool valid = IsDropValid(DragAndDrop.paths, destFolder); DragAndDrop.visualMode = valid ? DragAndDropVisualMode.Move : DragAndDropVisualMode.Rejected; UpdateDragTargetVisual(destFolder, valid); } private void OnDragPerform(DragPerformEvent evt) { var destFolder = GetDropDestinationFromVisualTarget(evt.target as VisualElement); bool valid = IsDropValid(DragAndDrop.paths, destFolder); if (valid && DragAndDrop.paths != null && DragAndDrop.paths.Length > 0) { MoveAssetsToFolder(DragAndDrop.paths, destFolder); DragAndDrop.AcceptDrag(); } else { // do nothing (either invalid or all no-op) // Optional: 显示提示 // Debug.Log("Drop rejected or no-op."); } ClearDragTarget(); } private string GetDropDestinationFromVisualTarget(VisualElement t) { while (t != null) { if (t.userData is string dataPath) { // if it's a folder path, return directly if (AssetDatabase.IsValidFolder(dataPath)) { return dataPath; } else { // userData is an asset (prefab) -> return its parent folder var parent = Path.GetDirectoryName(dataPath); if (string.IsNullOrEmpty(parent)) return Def_UXGUIPath.UIResRootPath; return parent.Replace("\\", "/"); } } t = t.parent as VisualElement; } // no path ancestor -> drop to root return Def_UXGUIPath.UIResRootPath; } /// /// Validate drop: prevent moving folder into itself or into its child. /// Also reject if **all** source items would be no-ops (i.e. already in destFolder). /// private bool IsDropValid(string[] srcPaths, string destFolder) { if (srcPaths == null || srcPaths.Length == 0) return false; if (!AssetDatabase.IsValidFolder(destFolder)) destFolder = Def_UXGUIPath.UIResRootPath; bool anyValidMove = false; foreach (var src in srcPaths) { if (string.IsNullOrEmpty(src)) continue; if (AssetDatabase.IsValidFolder(src)) { // cannot move folder into itself or its descendants if (destFolder.Equals(src, StringComparison.InvariantCultureIgnoreCase)) continue; if (destFolder.StartsWith(src + "/", StringComparison.InvariantCultureIgnoreCase)) continue; // into its child -> invalid // if the folder's parent already equals destFolder => that item would be no-op var parent = Path.GetDirectoryName(src); if (!string.IsNullOrEmpty(parent)) parent = parent.Replace("\\", "/"); if (string.Equals(parent, destFolder, StringComparison.InvariantCultureIgnoreCase)) { // this src would be no-op, continue checking others continue; } // otherwise it's a valid move anyValidMove = true; } else { // file/prefab: parent folder var parent = Path.GetDirectoryName(src); if (!string.IsNullOrEmpty(parent)) parent = parent.Replace("\\", "/"); if (string.Equals(parent, destFolder, StringComparison.InvariantCultureIgnoreCase)) { // would be no-op continue; } anyValidMove = true; } } return anyValidMove; } /// /// Highlight target element and display drop hint in preview label. /// private void UpdateDragTargetVisual(string destFolder, bool valid) { // if same as current, only update visual text if (!string.IsNullOrEmpty(currentDragTargetPath) && string.Equals(currentDragTargetPath, destFolder, StringComparison.InvariantCultureIgnoreCase)) { previewLabel.text = valid ? $"Drop target: {destFolder}" : $"Invalid drop: {destFolder}"; return; } // clear previous ClearDragTargetHighlight(); currentDragTargetPath = destFolder; previewLabel.text = valid ? $"Drop target: {destFolder}" : $"Invalid drop or no-op: {destFolder}"; var el = FindElementByPathInTree(destFolder); if (el != null) { currentDragTargetElement = el; el.style.backgroundColor = new StyleColor(new Color(0.1f, 0.8f, 0.1f, valid ? 0.2f : 0.12f)); } else { currentDragTargetElement = treeScroll; treeScroll.style.backgroundColor = new StyleColor(new Color(0.1f, 0.8f, 0.1f, valid ? 0.08f : 0.05f)); } } private void ClearDragTarget() { currentDragTargetPath = null; previewLabel.text = string.Empty; ClearDragTargetHighlight(); } private void ClearDragTargetHighlight() { if (currentDragTargetElement != null) { currentDragTargetElement.style.backgroundColor = StyleKeyword.Null; currentDragTargetElement = null; } treeScroll.style.backgroundColor = StyleKeyword.Null; } /// /// Recursively search treeScroll children for VisualElement whose userData equals path. /// Returns that element (the header element we set userData on), or null. /// private VisualElement FindElementByPathInTree(string path) { if (string.IsNullOrEmpty(path)) return null; VisualElement found = null; void Search(VisualElement parent) { if (found != null) return; foreach (var c in parent.Children()) { if (c.userData is string s && string.Equals(s, path, StringComparison.InvariantCultureIgnoreCase)) { found = c; return; } Search(c); if (found != null) return; } } Search(treeScroll); return found; } private void MoveAssetsToFolder(string[] paths, string destFolder) { if (paths == null || paths.Length == 0) return; if (!AssetDatabase.IsValidFolder(destFolder)) destFolder = Def_UXGUIPath.UIResRootPath; var newSelection = new List(); foreach (var src in paths) { if (string.IsNullOrEmpty(src)) continue; if (AssetDatabase.IsValidFolder(src)) { if (destFolder.Equals(src, StringComparison.InvariantCultureIgnoreCase)) continue; if (destFolder.StartsWith(src + "/", StringComparison.InvariantCultureIgnoreCase)) continue; // cannot move into its own child // if parent already equals destFolder -> skip (no-op) var parent = Path.GetDirectoryName(src); if (!string.IsNullOrEmpty(parent)) parent = parent.Replace("\\", "/"); if (string.Equals(parent, destFolder, StringComparison.InvariantCultureIgnoreCase)) continue; } else { // file/prefab: skip if already in destFolder var parent = Path.GetDirectoryName(src); if (!string.IsNullOrEmpty(parent)) parent = parent.Replace("\\", "/"); if (string.Equals(parent, destFolder, StringComparison.InvariantCultureIgnoreCase)) continue; } var fileName = Path.GetFileName(src); var destPath = PathCombine(destFolder, fileName); destPath = AssetDatabase.GenerateUniqueAssetPath(destPath); var err = AssetDatabase.MoveAsset(src, destPath); if (!string.IsNullOrEmpty(err)) { Debug.LogWarning($"Failed to move {src} -> {destPath}: {err}"); } else { newSelection.Add(destPath); } } AssetDatabase.Refresh(); if (newSelection.Count > 0) { selectedPaths.Clear(); foreach (var s in newSelection) selectedPaths.Add(s); } RefreshTree(); } // ----- Renaming support (F2) ----- private void StartRename(string assetPath) { if (string.IsNullOrEmpty(assetPath)) return; var el = FindElementByPathInTree(assetPath); if (el == null) return; // find first Label child to replace Label nameLabel = null; foreach (var c in el.Children()) { if (c is Label l) { nameLabel = l; break; } } if (nameLabel == null) return; // current base name and extension for files string currentBase; string extension = ""; if (AssetDatabase.IsValidFolder(assetPath)) { currentBase = Path.GetFileName(assetPath); } else { extension = Path.GetExtension(assetPath); currentBase = Path.GetFileNameWithoutExtension(assetPath); } // create TextField and insert at same position int index = 0; foreach (var c in el.Children()) { if (c == nameLabel) break; index++; } var tf = new TextField(); tf.value = currentBase; tf.style.flexGrow = 1; tf.RegisterCallback(ke => { if (ke.keyCode == KeyCode.Return || ke.keyCode == KeyCode.KeypadEnter) { CommitRename(assetPath, tf.value, extension); ke.StopPropagation(); } else if (ke.keyCode == KeyCode.Escape) { CancelRename(); ke.StopPropagation(); } }); tf.RegisterCallback(fe => { // commit on lose focus CommitRename(assetPath, tf.value, extension); }); el.Insert(index, tf); nameLabel.style.display = DisplayStyle.None; // focus and select text tf.Focus(); tf.SelectAll(); } private void CancelRename() { // easiest: just refresh tree to restore UI RefreshTree(); } private void CommitRename(string originalPath, string newBaseName, string extension) { if (string.IsNullOrWhiteSpace(newBaseName)) { // cancel rename if empty RefreshTree(); return; } string parent = Path.GetDirectoryName(originalPath); if (!string.IsNullOrEmpty(parent)) parent = parent.Replace("\\", "/"); if (AssetDatabase.IsValidFolder(originalPath)) { // rename folder (newBaseName is folder name) var err = AssetDatabase.RenameAsset(originalPath, newBaseName); if (!string.IsNullOrEmpty(err)) { Debug.LogWarning("Rename failed: " + err); } else { AssetDatabase.Refresh(); var newFolderPath = PathCombine(parent, newBaseName); selectedPaths.Clear(); selectedPaths.Add(newFolderPath); RefreshTree(); } } else { // file/prefab: append extension if missing string ext = extension; if (string.IsNullOrEmpty(ext)) ext = Path.GetExtension(originalPath); var newName = newBaseName + ext; var err = AssetDatabase.RenameAsset(originalPath, newName); if (!string.IsNullOrEmpty(err)) { Debug.LogWarning("Rename failed: " + err); } else { AssetDatabase.Refresh(); var newAssetPath = PathCombine(parent, newName); selectedPaths.Clear(); selectedPaths.Add(newAssetPath); RefreshTree(); } } } }