From eeca4973ae43784e82ef14c69718219f7b264638 Mon Sep 17 00:00:00 2001 From: Michal Pikulski Date: Thu, 30 Oct 2025 10:29:57 +0100 Subject: [PATCH] Add component search+replace --- .../Tools/ComponentSearchReplaceWindow.cs | 550 ++++++++++++++++++ .../ComponentSearchReplaceWindow.cs.meta | 3 + 2 files changed, 553 insertions(+) create mode 100644 Assets/Editor/Tools/ComponentSearchReplaceWindow.cs create mode 100644 Assets/Editor/Tools/ComponentSearchReplaceWindow.cs.meta diff --git a/Assets/Editor/Tools/ComponentSearchReplaceWindow.cs b/Assets/Editor/Tools/ComponentSearchReplaceWindow.cs new file mode 100644 index 00000000..3b0160e3 --- /dev/null +++ b/Assets/Editor/Tools/ComponentSearchReplaceWindow.cs @@ -0,0 +1,550 @@ +using UnityEditor; +using UnityEngine; +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEditor.SceneManagement; +using UnityEngine.SceneManagement; +using UnityEditor.IMGUI.Controls; + +namespace Editor.Tools +{ + public class ComponentSearchReplaceWindow : EditorWindow + { + private Type selectedSearchType; + private Type selectedReplaceType; + private List foundComponents = new List(); + private Vector2 scrollPos; + private int selectedComponentIndex = -1; + private string searchTypeName = "Select a Component..."; + private string replaceTypeName = "Select a Component..."; + private List allMonoBehaviourTypes = new List(); + + [MenuItem("Tools/Component Search & Replace")] + public static void ShowWindow() + { + var window = GetWindow("Component Search & Replace"); + window.minSize = new Vector2(700, 500); + window.maxSize = new Vector2(1400, 1000); + window.position = new Rect(120, 120, 800, 600); + } + + private void OnEnable() + { + CacheMonoBehaviourTypes(); + EditorSceneManager.sceneOpened += (scene, mode) => ClearSearch(); + SceneManager.activeSceneChanged += (oldScene, newScene) => ClearSearch(); + } + + private void OnDisable() + { + EditorSceneManager.sceneOpened -= (scene, mode) => ClearSearch(); + SceneManager.activeSceneChanged -= (oldScene, newScene) => ClearSearch(); + } + + private void CacheMonoBehaviourTypes() + { + allMonoBehaviourTypes.Clear(); + var assemblies = AppDomain.CurrentDomain.GetAssemblies(); + + foreach (var assembly in assemblies) + { + try + { + var types = assembly.GetTypes() + .Where(t => (t.IsSubclassOf(typeof(MonoBehaviour)) || t.IsSubclassOf(typeof(Component))) + && !t.IsAbstract + && t != typeof(Transform)) // Exclude Transform as it can't be removed + .OrderBy(t => t.Name); + allMonoBehaviourTypes.AddRange(types); + } + catch (Exception) + { + // Some assemblies may throw exceptions when getting types + continue; + } + } + } + + private void ClearSearch() + { + foundComponents.Clear(); + selectedComponentIndex = -1; + Repaint(); + } + + private void OnGUI() + { + GUILayout.Space(10); + + // Search Section + EditorGUILayout.BeginVertical("box"); + EditorGUILayout.LabelField("Search for Component", EditorStyles.boldLabel); + GUILayout.Space(5); + + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField("Component Type:", GUILayout.Width(120)); + + if (GUILayout.Button(searchTypeName, EditorStyles.popup, GUILayout.ExpandWidth(true))) + { + var dropdown = new ComponentTypeDropdown( + new AdvancedDropdownState(), + (type) => + { + selectedSearchType = type; + searchTypeName = type != null ? type.Name : "Select a Component..."; + ClearSearch(); + Repaint(); + }); + dropdown.Show(GUILayoutUtility.GetLastRect()); + } + EditorGUILayout.EndHorizontal(); + + GUILayout.Space(5); + + EditorGUI.BeginDisabledGroup(selectedSearchType == null); + if (GUILayout.Button("Search Scene", GUILayout.Height(30))) + { + SearchForComponents(); + } + EditorGUI.EndDisabledGroup(); + + EditorGUILayout.EndVertical(); + + GUILayout.Space(10); + + // Results Section + EditorGUILayout.BeginVertical("box"); + EditorGUILayout.LabelField($"Search Results ({foundComponents.Count} found)", EditorStyles.boldLabel); + GUILayout.Space(5); + + scrollPos = EditorGUILayout.BeginScrollView(scrollPos, GUILayout.ExpandHeight(true)); + + if (foundComponents.Count == 0) + { + EditorGUILayout.HelpBox("No components found. Select a component type and click 'Search Scene'.", MessageType.Info); + } + else + { + for (int i = 0; i < foundComponents.Count; i++) + { + var info = foundComponents[i]; + if (info.component == null) continue; + + bool isSelected = selectedComponentIndex == i; + Color originalColor = GUI.backgroundColor; + + if (isSelected) + { + GUI.backgroundColor = new Color(0.3f, 0.6f, 1f, 0.5f); + } + + EditorGUILayout.BeginVertical("box"); + GUI.backgroundColor = originalColor; + + EditorGUILayout.BeginHorizontal(); + + // Selection toggle + bool newSelected = GUILayout.Toggle(isSelected, "", GUILayout.Width(20)); + if (newSelected != isSelected) + { + selectedComponentIndex = newSelected ? i : -1; + } + + EditorGUILayout.BeginVertical(); + EditorGUILayout.LabelField($"GameObject: {info.gameObject.name}", EditorStyles.boldLabel); + EditorGUILayout.LabelField($"Path: {info.hierarchyPath}", EditorStyles.miniLabel); + EditorGUILayout.LabelField($"Component: {info.component.GetType().Name}", EditorStyles.miniLabel); + EditorGUILayout.EndVertical(); + + if (GUILayout.Button("Ping", GUILayout.Width(80))) + { + Selection.activeObject = info.gameObject; + EditorGUIUtility.PingObject(info.gameObject); + SceneView.FrameLastActiveSceneView(); + } + + EditorGUILayout.EndHorizontal(); + EditorGUILayout.EndVertical(); + + GUILayout.Space(2); + } + } + + EditorGUILayout.EndScrollView(); + EditorGUILayout.EndVertical(); + + GUILayout.Space(10); + + // Replace Section + EditorGUILayout.BeginVertical("box"); + EditorGUILayout.LabelField("Replace Component", EditorStyles.boldLabel); + GUILayout.Space(5); + + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField("Replace With:", GUILayout.Width(120)); + + if (GUILayout.Button(replaceTypeName, EditorStyles.popup, GUILayout.ExpandWidth(true))) + { + var dropdown = new ComponentTypeDropdown( + new AdvancedDropdownState(), + (type) => + { + selectedReplaceType = type; + replaceTypeName = type != null ? type.Name : "Select a Component..."; + Repaint(); + }); + dropdown.Show(GUILayoutUtility.GetLastRect()); + } + EditorGUILayout.EndHorizontal(); + + GUILayout.Space(5); + + bool canReplace = selectedComponentIndex >= 0 && + selectedComponentIndex < foundComponents.Count && + selectedReplaceType != null; + + EditorGUI.BeginDisabledGroup(!canReplace); + + if (!canReplace && selectedComponentIndex >= 0 && selectedReplaceType == null) + { + EditorGUILayout.HelpBox("Select a component type to replace with.", MessageType.Warning); + } + else if (!canReplace && selectedReplaceType != null) + { + EditorGUILayout.HelpBox("Select an object from the search results above.", MessageType.Warning); + } + + if (GUILayout.Button("Replace Selected Component", GUILayout.Height(30))) + { + ReplaceComponent(); + } + + EditorGUI.EndDisabledGroup(); + + EditorGUILayout.EndVertical(); + + GUILayout.Space(5); + } + + private void SearchForComponents() + { + if (selectedSearchType == null) + { + Debug.LogWarning("No component type selected for search."); + return; + } + + foundComponents.Clear(); + selectedComponentIndex = -1; + + var allObjects = FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); + + foreach (var go in allObjects) + { + var component = go.GetComponent(selectedSearchType); + if (component != null) + { + foundComponents.Add(new ComponentInfo + { + gameObject = go, + component = component, + hierarchyPath = GetHierarchyPath(go) + }); + } + } + + foundComponents = foundComponents.OrderBy(c => c.hierarchyPath).ToList(); + + Debug.Log($"Found {foundComponents.Count} objects with component type '{selectedSearchType.Name}'"); + Repaint(); + } + + private void ReplaceComponent() + { + if (selectedComponentIndex < 0 || selectedComponentIndex >= foundComponents.Count) + { + Debug.LogError("No component selected to replace."); + return; + } + + if (selectedReplaceType == null) + { + Debug.LogError("No replacement component type selected."); + return; + } + + var info = foundComponents[selectedComponentIndex]; + var go = info.gameObject; + var oldComponent = info.component; + + if (go == null || oldComponent == null) + { + Debug.LogError("Selected component or GameObject is null."); + foundComponents.RemoveAt(selectedComponentIndex); + selectedComponentIndex = -1; + Repaint(); + return; + } + + // Confirm the replacement + bool confirmed = EditorUtility.DisplayDialog( + "Replace Component", + $"Are you sure you want to replace '{selectedSearchType.Name}' with '{selectedReplaceType.Name}' on GameObject '{go.name}'?\n\n" + + "This action can be undone with Ctrl+Z.", + "Replace", + "Cancel" + ); + + if (!confirmed) + { + return; + } + + // Record undo + Undo.RegisterCompleteObjectUndo(go, "Replace Component"); + + // Copy serialized data if possible (optional enhancement) + // This attempts to preserve common fields between components + var serializedOldComponent = new SerializedObject(oldComponent); + + // Remove old component + Undo.DestroyObjectImmediate(oldComponent); + + // Add new component + var newComponent = Undo.AddComponent(go, selectedReplaceType); + + // Try to copy matching serialized properties + var serializedNewComponent = new SerializedObject(newComponent); + CopyMatchingSerializedProperties(serializedOldComponent, serializedNewComponent); + serializedNewComponent.ApplyModifiedProperties(); + + // Mark scene as dirty + EditorSceneManager.MarkSceneDirty(go.scene); + + Debug.Log($"Replaced '{selectedSearchType.Name}' with '{selectedReplaceType.Name}' on '{go.name}'"); + + // Update the found components list + foundComponents[selectedComponentIndex] = new ComponentInfo + { + gameObject = go, + component = newComponent, + hierarchyPath = info.hierarchyPath + }; + + // Update search type if needed + if (selectedSearchType != selectedReplaceType) + { + foundComponents.RemoveAt(selectedComponentIndex); + selectedComponentIndex = -1; + } + + Repaint(); + } + + private void CopyMatchingSerializedProperties(SerializedObject source, SerializedObject destination) + { + SerializedProperty iterator = source.GetIterator(); + bool enterChildren = true; + + while (iterator.NextVisible(enterChildren)) + { + enterChildren = false; + + // Skip script reference + if (iterator.propertyPath == "m_Script") + continue; + + SerializedProperty destProp = destination.FindProperty(iterator.propertyPath); + if (destProp != null && destProp.propertyType == iterator.propertyType) + { + try + { + destination.CopyFromSerializedProperty(iterator); + } + catch + { + // Some properties may not be copyable + } + } + } + } + + private string GetHierarchyPath(GameObject go) + { + string path = go.name; + Transform t = go.transform.parent; + while (t != null) + { + path = t.name + "/" + path; + t = t.parent; + } + return path; + } + + private class ComponentInfo + { + public GameObject gameObject; + public Component component; + public string hierarchyPath; + } + } + + // Advanced Dropdown for Component Type Selection (including Unity built-in components) + public class ComponentTypeDropdown : AdvancedDropdown + { + private Action onTypeSelected; + private static List cachedTypes; + + public ComponentTypeDropdown(AdvancedDropdownState state, Action onTypeSelected) + : base(state) + { + this.onTypeSelected = onTypeSelected; + minimumSize = new Vector2(300, 400); + + if (cachedTypes == null) + { + CacheTypes(); + } + } + + private static void CacheTypes() + { + cachedTypes = new List(); + var assemblies = AppDomain.CurrentDomain.GetAssemblies(); + + foreach (var assembly in assemblies) + { + try + { + var types = assembly.GetTypes() + .Where(t => (t.IsSubclassOf(typeof(MonoBehaviour)) || t.IsSubclassOf(typeof(Component))) + && !t.IsAbstract + && t != typeof(Transform)) // Exclude Transform as it can't be removed + .OrderBy(t => t.Name); + cachedTypes.AddRange(types); + } + catch (Exception) + { + // Some assemblies may throw exceptions when getting types + } + } + } + + protected override AdvancedDropdownItem BuildRoot() + { + var root = new AdvancedDropdownItem("Component Types"); + + // Add "None" option + var noneItem = new TypeDropdownItem("None", null); + root.AddChild(noneItem); + + root.AddSeparator(); + + // Separate Unity built-in components from custom ones + var unityComponents = cachedTypes.Where(t => + t.Namespace != null && t.Namespace.StartsWith("UnityEngine")).ToList(); + var customComponents = cachedTypes.Except(unityComponents).ToList(); + + // Add Unity Components section + if (unityComponents.Any()) + { + var unitySection = new AdvancedDropdownItem("Unity Components"); + + // Group Unity components by category (second part of namespace) + var unityGrouped = unityComponents.GroupBy(t => + { + if (t.Namespace == "UnityEngine") + return "Core"; + var parts = t.Namespace.Split('.'); + return parts.Length > 1 ? parts[1] : "Other"; + }).OrderBy(g => g.Key); + + foreach (var group in unityGrouped) + { + if (group.Count() <= 5) + { + // Add directly if few items + foreach (var type in group.OrderBy(t => t.Name)) + { + var typeItem = new TypeDropdownItem(type.Name, type); + unitySection.AddChild(typeItem); + } + } + else + { + // Create subcategory + var categoryItem = new AdvancedDropdownItem(group.Key); + foreach (var type in group.OrderBy(t => t.Name)) + { + var typeItem = new TypeDropdownItem(type.Name, type); + categoryItem.AddChild(typeItem); + } + unitySection.AddChild(categoryItem); + } + } + + root.AddChild(unitySection); + } + + // Add Custom Components section + if (customComponents.Any()) + { + root.AddSeparator(); + + // Group by namespace + var groupedTypes = customComponents.GroupBy(t => + string.IsNullOrEmpty(t.Namespace) ? "(No Namespace)" : t.Namespace); + + foreach (var group in groupedTypes.OrderBy(g => g.Key)) + { + // For small namespaces or no namespace, add directly to root + if (group.Count() <= 3 || group.Key == "(No Namespace)") + { + foreach (var type in group) + { + var typeItem = new TypeDropdownItem( + group.Key == "(No Namespace)" ? type.Name : $"{group.Key}.{type.Name}", + type); + root.AddChild(typeItem); + } + } + else + { + // Create namespace folder + var namespaceItem = new AdvancedDropdownItem(group.Key); + + foreach (var type in group) + { + var typeItem = new TypeDropdownItem(type.Name, type); + namespaceItem.AddChild(typeItem); + } + + root.AddChild(namespaceItem); + } + } + } + + return root; + } + + protected override void ItemSelected(AdvancedDropdownItem item) + { + var typeItem = item as TypeDropdownItem; + if (typeItem != null) + { + onTypeSelected?.Invoke(typeItem.Type); + } + } + + private class TypeDropdownItem : AdvancedDropdownItem + { + public Type Type { get; private set; } + + public TypeDropdownItem(string name, Type type) : base(name) + { + Type = type; + } + } + } +} diff --git a/Assets/Editor/Tools/ComponentSearchReplaceWindow.cs.meta b/Assets/Editor/Tools/ComponentSearchReplaceWindow.cs.meta new file mode 100644 index 00000000..6e8711d1 --- /dev/null +++ b/Assets/Editor/Tools/ComponentSearchReplaceWindow.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 604b18a354fa44a49723ab9f9173762e +timeCreated: 1761816008 \ No newline at end of file