using System;
using System.Collections.Generic; // Added for HashSet
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using Codely.Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using UnityTcp.Editor.Helpers; // For Response class
namespace UnityTcp.Editor.Tools
{
///
/// Handles executing Unity Editor menu items by path.
///
public static class ExecuteMenuItem
{
// Basic blacklist to prevent accidental execution of potentially disruptive menu items.
// This can be expanded based on needs.
private static readonly HashSet _menuPathBlacklist = new HashSet(
StringComparer.OrdinalIgnoreCase
)
{
"File/Quit",
// Add other potentially dangerous items like "Edit/Preferences...", "File/Build Settings..." if needed
};
private const string TmpEssentialsImportMenuPath =
"Window/TextMeshPro/Import TMP Essential Resources";
///
/// Main handler for executing menu items or getting available ones.
///
private static readonly Dictionary> ActionHandlers =
new Dictionary>
{
{ "execute", ExecuteItem },
{ "get_available_menus", GetAvailableMenus },
};
public static object HandleCommand(JObject @params)
=> ActionRouter.Route(@params, ActionHandlers, defaultAction: "execute");
///
/// Executes a specific menu item.
///
private static object ExecuteItem(JObject @params)
{
// Try both naming conventions: snake_case and camelCase
string menuPath = @params["menu_path"]?.ToString() ?? @params["menuPath"]?.ToString();
// Optional future param retained for API compatibility; not used in synchronous mode
// int timeoutMs = Math.Max(0, (@params["timeout_ms"]?.ToObject() ?? 2000));
// string alias = @params["alias"]?.ToString(); // TODO: Implement alias mapping based on refactor plan requirements.
// JObject parameters = @params["parameters"] as JObject; // TODO: Investigate parameter passing (often not directly supported by ExecuteMenuItem).
if (string.IsNullOrWhiteSpace(menuPath))
{
return Response.Error("Required parameter 'menu_path' or 'menuPath' is missing or empty.");
}
// Validate against blacklist
if (_menuPathBlacklist.Contains(menuPath))
{
return Response.Error(
$"Execution of menu item '{menuPath}' is blocked for safety reasons."
);
}
// TODO: Implement alias lookup here if needed (Map alias to actual menuPath).
// if (!string.IsNullOrEmpty(alias)) { menuPath = LookupAlias(alias); if(menuPath == null) return Response.Error(...); }
// TODO: Handle parameters ('parameters' object) if a viable method is found.
// This is complex as EditorApplication.ExecuteMenuItem doesn't take arguments directly.
// It might require finding the underlying EditorWindow or command if parameters are needed.
try
{
// Trace incoming execute requests (debug-gated)
CodelyLogger.Verbose($"[ExecuteMenuItem] Request to execute menu: '{menuPath}'");
if (string.Equals(menuPath, TmpEssentialsImportMenuPath, StringComparison.Ordinal))
{
TmpEssentialsAutoImporter.ScheduleImport();
return Response.Success(
$"Scheduled TMP essential resources import for menu item: '{menuPath}'",
new { executed = true, menuPath }
);
}
// Execute synchronously. This code runs on the Editor main thread in our bridge path.
bool executed = EditorApplication.ExecuteMenuItem(menuPath);
if (executed)
{
// Success trace (debug-gated)
CodelyLogger.Verbose($"[ExecuteMenuItem] Executed successfully: '{menuPath}'");
return Response.Success(
$"Executed menu item: '{menuPath}'",
new { executed = true, menuPath }
);
}
CodelyLogger.LogWarning($"[ExecuteMenuItem] Failed (not found/disabled): '{menuPath}'");
return Response.Error(
$"Failed to execute menu item (not found or disabled): '{menuPath}'",
new { executed = false, menuPath }
);
}
catch (Exception e)
{
CodelyLogger.LogError($"[ExecuteMenuItem] Error executing '{menuPath}': {e}");
return Response.Error($"Error executing menu item '{menuPath}': {e.Message}");
}
}
// TODO: Add helper for alias lookup if implementing aliases.
// private static string LookupAlias(string alias) { ... return actualMenuPath or null ... }
// --- get_available_menus support ---------------------------------------------------
// Standard built-in top-level Unity menus. These are native (C++-defined) and so are
// invisible to TypeCache; we keep them as a baseline. Custom top-level menus such as
// "AI" are added dynamically in EnumerateTopLevelSeeds(). Querying a menu that does not
// exist simply returns no items, so over-seeding is harmless.
private static readonly string[] _topLevelMenus =
{
"File", "Edit", "Assets", "GameObject", "Component", "Window", "Help", "Tools", "Mobile"
};
// Cached reflection handles for UnityEditor.Menu.GetMenuItems(string, bool, bool).
private static bool _menuReflectionResolved;
private static MethodInfo _getMenuItemsMethod;
// 'path' PropertyInfo cached per concrete item runtime type. Keying by type keeps the
// lookup cheap while staying correct if GetMenuItems ever returns more than one item
// type (e.g. a separator type alongside regular items). A null value is cached too,
// so a type that genuinely lacks a 'path' property is not re-probed every item.
private static readonly Dictionary _pathPropByType =
new Dictionary();
///
/// Enumerates available Editor menu item paths.
/// Optional params: 'menu_path'/'menuPath' to scope to a subtree (default: all),
/// 'filter'/'search' for a case-insensitive substring match, and
/// 'include_separators'/'includeSeparators' (default: false).
///
private static object GetAvailableMenus(JObject @params)
{
string rootPath = @params["menu_path"]?.ToString() ?? @params["menuPath"]?.ToString() ?? string.Empty;
rootPath = (rootPath ?? string.Empty).Trim().TrimEnd('/');
string filter = @params["filter"]?.ToString() ?? @params["search"]?.ToString();
bool includeSeparators = @params["include_separators"]?.ToObject()
?? @params["includeSeparators"]?.ToObject() ?? false;
var paths = new HashSet(StringComparer.Ordinal);
// Primary source: native UnityEditor.Menu.GetMenuItems via reflection. This
// captures built-in (C++-defined) menu items as well as script-defined ones.
string reflectionError = TryCollectMenuPathsViaReflection(rootPath, includeSeparators, paths);
// Supplement with [MenuItem]-attributed methods discovered via TypeCache. Merged
// unconditionally into the same set so duplicates collapse, ensuring script-defined
// items are always present even when the reflection API only returned a partial
// subtree (e.g. immediate children) or was unavailable entirely.
CollectMenuPathsViaTypeCache(rootPath, paths);
if (paths.Count == 0 && reflectionError != null)
{
return Response.Error($"Unable to enumerate menu items: {reflectionError}");
}
IEnumerable result = paths;
if (!string.IsNullOrEmpty(filter))
{
result = result.Where(p => p.IndexOf(filter, StringComparison.OrdinalIgnoreCase) >= 0);
}
var sorted = result.OrderBy(p => p, StringComparer.OrdinalIgnoreCase).ToList();
CodelyLogger.Verbose(
$"[ExecuteMenuItem] get_available_menus -> {sorted.Count} item(s) (root='{rootPath}', filter='{filter}').");
return Response.Success($"Found {sorted.Count} menu item(s).", sorted);
}
///
/// Walks the Editor menu tree using the internal UnityEditor.Menu.GetMenuItems API.
/// Returns null on success, or an error message if the API could not be used.
///
private static string TryCollectMenuPathsViaReflection(
string rootPath, bool includeSeparators, HashSet outPaths)
{
EnsureMenuReflection();
if (_getMenuItemsMethod == null)
{
return "UnityEditor.Menu.GetMenuItems(string, bool, bool) was not found on this Unity version.";
}
try
{
var expanded = new HashSet(StringComparer.Ordinal);
var queue = new Queue();
queue.Enqueue(rootPath ?? string.Empty);
// When listing everything, also seed the top-level menus in case the
// empty-root query is not honored on this Unity version.
if (string.IsNullOrEmpty(rootPath))
{
foreach (string top in EnumerateTopLevelSeeds())
{
queue.Enqueue(top);
}
}
// Guard against pathological loops; real menu trees are far smaller.
int guard = 0;
const int maxExpansions = 20000;
while (queue.Count > 0 && guard++ < maxExpansions)
{
string q = queue.Dequeue();
if (!expanded.Add(q)) continue;
string[] childPaths = InvokeGetMenuItems(q, includeSeparators);
if (childPaths == null || childPaths.Length == 0) continue;
// GetMenuItems may return either the full subtree (deep paths) or only the
// immediate children of 'q'. Detect which by checking relative depth.
string prefix = q.Length == 0 ? string.Empty : q + "/";
bool returnedSubtree = false;
foreach (string p in childPaths)
{
if (string.IsNullOrEmpty(p)) continue;
outPaths.Add(p);
string rel = (prefix.Length > 0 && p.StartsWith(prefix, StringComparison.Ordinal))
? p.Substring(prefix.Length)
: p;
if (rel.IndexOf('/') >= 0) returnedSubtree = true;
}
// Only walk deeper when we got immediate children; otherwise everything
// beneath 'q' is already collected.
if (!returnedSubtree)
{
foreach (string p in childPaths)
{
if (!string.IsNullOrEmpty(p)) queue.Enqueue(p);
}
}
}
return null;
}
catch (Exception e)
{
CodelyLogger.LogWarning($"[ExecuteMenuItem] Reflection enumeration failed: {e.Message}");
return e.Message;
}
}
///
/// Invokes the cached GetMenuItems method for a single menu path and returns the
/// reported item paths. Returns an empty array if nothing is found.
///
private static string[] InvokeGetMenuItems(string menuPath, bool includeSeparators)
{
System.Array items;
try
{
items = _getMenuItemsMethod.Invoke(
null,
new object[] { menuPath ?? string.Empty, includeSeparators, false }
) as System.Array;
}
catch (Exception)
{
return null;
}
if (items == null || items.Length == 0) return Array.Empty();
var result = new List(items.Length);
for (int i = 0; i < items.Length; i++)
{
object item = items.GetValue(i);
if (item == null) continue;
Type itemType = item.GetType();
if (!_pathPropByType.TryGetValue(itemType, out PropertyInfo pathProp))
{
pathProp = itemType.GetProperty(
"path",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic
);
_pathPropByType[itemType] = pathProp;
}
string path = pathProp?.GetValue(item) as string;
if (!string.IsNullOrEmpty(path)) result.Add(path);
}
return result.ToArray();
}
///
/// Returns the set of top-level menu names used to seed enumeration when the
/// empty-root query is not honored. Combines the standard built-in menus (native,
/// hence invisible to TypeCache) with the first path segment of every [MenuItem]
/// discovered via TypeCache, so custom top-level menus (e.g. "AI/...") are not missed.
///
private static IEnumerable EnumerateTopLevelSeeds()
{
var seeds = new HashSet(_topLevelMenus, StringComparer.Ordinal);
try
{
foreach (MethodInfo method in TypeCache.GetMethodsWithAttribute