Add component search+replace
This commit is contained in:
550
Assets/Editor/Tools/ComponentSearchReplaceWindow.cs
Normal file
550
Assets/Editor/Tools/ComponentSearchReplaceWindow.cs
Normal file
@@ -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<ComponentInfo> foundComponents = new List<ComponentInfo>();
|
||||
private Vector2 scrollPos;
|
||||
private int selectedComponentIndex = -1;
|
||||
private string searchTypeName = "Select a Component...";
|
||||
private string replaceTypeName = "Select a Component...";
|
||||
private List<Type> allMonoBehaviourTypes = new List<Type>();
|
||||
|
||||
[MenuItem("Tools/Component Search & Replace")]
|
||||
public static void ShowWindow()
|
||||
{
|
||||
var window = GetWindow<ComponentSearchReplaceWindow>("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<GameObject>(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<Type> onTypeSelected;
|
||||
private static List<Type> cachedTypes;
|
||||
|
||||
public ComponentTypeDropdown(AdvancedDropdownState state, Action<Type> onTypeSelected)
|
||||
: base(state)
|
||||
{
|
||||
this.onTypeSelected = onTypeSelected;
|
||||
minimumSize = new Vector2(300, 400);
|
||||
|
||||
if (cachedTypes == null)
|
||||
{
|
||||
CacheTypes();
|
||||
}
|
||||
}
|
||||
|
||||
private static void CacheTypes()
|
||||
{
|
||||
cachedTypes = new List<Type>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
3
Assets/Editor/Tools/ComponentSearchReplaceWindow.cs.meta
Normal file
3
Assets/Editor/Tools/ComponentSearchReplaceWindow.cs.meta
Normal file
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 604b18a354fa44a49723ab9f9173762e
|
||||
timeCreated: 1761816008
|
||||
Reference in New Issue
Block a user