Files
AIC-Project/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/MenuItemCommand.cs
T

244 lines
10 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Newtonsoft.Json;
using Unity.Pipeline.Commands;
using UnityEditor;
namespace Unity.Pipeline.Editor.Commands
{
/// <summary>
/// Executes an Editor menu item by its path (e.g. "Assets/Reimport All"), or — when called with
/// no path — lists the available menu items. Backs the <c>unity menu</c> CLI command (CLI-110):
/// <c>unity menu</c> lists options, <c>unity menu "&lt;path&gt;"</c> executes one. The CLI only
/// forwards the request; all logic lives here, per the two-commands design.
///
/// Named <c>MenuItemCommand</c> (not <c>MenuCommand</c>) to avoid colliding with the built-in
/// <see cref="UnityEditor.MenuCommand"/> type, which would make unqualified references ambiguous
/// in any file that also imports <c>UnityEditor</c>.
///
/// Execution wraps <see cref="EditorApplication.ExecuteMenuItem"/>, which returns <c>false</c>
/// when the item could not be invoked — either because the path does not exist or because the
/// item is currently disabled (its validation function returned false). The API does not
/// distinguish those cases, so a failure is reported as "not found or disabled"; the CLI maps a
/// failed result to its "menu item does not exist" exit code.
///
/// Listing reflects the <em>actual</em> Editor menu via the internal
/// <c>UnityEditor.Menu.GetMenuItems</c> API (accessed by reflection, mirroring
/// <see cref="FocusEditorCommand"/>). Unlike scanning <c>[MenuItem]</c> attributes, this also
/// covers menus Unity defines natively (much of <c>GameObject/…</c>, <c>Assets/Create/…</c>,
/// <c>File/…</c>, etc.), which carry no attribute and would otherwise be missed.
///
/// Note: some menu items are heavy, show a modal confirmation, or trigger a domain reload/quit
/// (which tears the server down before it can reply). That is the nature of the invoked item;
/// such cases are a known limitation of driving arbitrary menus over a request/response call.
/// </summary>
public static class MenuItemCommand
{
[CliCommand("menu", "Execute an Editor menu item by path, or list available items when no path is given", MainThreadRequired = true)]
public static MenuResponse ExecuteMenu(
[CliArg("path", "Menu item path to execute, e.g. \"Assets/Reimport All\". Omit to list available menu items.")] string path = "")
{
// No path → discovery mode: return the available menu items instead of executing.
if (string.IsNullOrWhiteSpace(path))
{
List<string> items;
try
{
items = DiscoverMenuItems();
}
catch (Exception ex)
{
return MenuResponse.Fail(null, $"Failed to list menu items: {ex.Message}");
}
return new MenuResponse
{
Success = true,
Items = items,
Message = $"{items.Count} menu item(s) available."
};
}
bool executed;
try
{
executed = EditorApplication.ExecuteMenuItem(path);
}
catch (Exception ex)
{
return MenuResponse.Fail(path, $"Failed to execute menu item '{path}': {ex.Message}");
}
if (!executed)
return MenuResponse.Fail(path, $"Menu item '{path}' was not found or is currently disabled.");
return new MenuResponse
{
Success = true,
Path = path,
Message = $"Executed menu item '{path}'"
};
}
const BindingFlags k_All = BindingFlags.Static | BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
// Reflection handles for the internal UnityEditor.Menu.GetMenuItems(string, bool, bool) API,
// which returns an array of internal UnityEditor.ScriptingMenuItem. Resolved once and cached;
// we read each item's `path` and `isSeparator` member (property or field, depending on the
// Unity version). See FocusEditorCommand for the same reflect-into-internal-API approach.
static MethodInfo s_GetMenuItems;
static Func<object, string> s_GetPath;
static Func<object, bool> s_GetIsSeparator;
static bool s_Resolved;
static void EnsureReflection()
{
if (s_Resolved)
return;
s_Resolved = true;
var editorAsm = typeof(EditorApplication).Assembly;
var menuType = editorAsm.GetType("UnityEditor.Menu");
s_GetMenuItems = menuType?.GetMethod(
"GetMenuItems", k_All, null, new[] { typeof(string), typeof(bool), typeof(bool) }, null);
var itemType = editorAsm.GetType("UnityEditor.ScriptingMenuItem");
s_GetPath = BuildAccessor<string>(itemType, "path", "m_Path");
s_GetIsSeparator = BuildAccessor<bool>(itemType, "isSeparator", "m_IsSeparator");
}
/// <summary>
/// Build a getter for a member that may be exposed as a property or a backing field, so the
/// reflection survives the internal type's shape changing across Unity versions.
/// </summary>
static Func<object, T> BuildAccessor<T>(Type type, string propertyName, string fieldName)
{
var prop = type?.GetProperty(propertyName, k_All);
if (prop != null)
return o => (T)prop.GetValue(o);
var field = type?.GetField(propertyName, k_All) ?? type?.GetField(fieldName, k_All);
if (field != null)
return o => (T)field.GetValue(o);
return null;
}
// Standard top-level Editor menus. Unity defines these natively (no [MenuItem]), so they are
// not discoverable from attributes; we seed them so their native children are enumerated.
static readonly string[] k_NativeRoots =
{
"File", "Edit", "Assets", "GameObject", "Component", "Window", "Help"
};
/// <summary>
/// Collect the distinct menu paths from the live Editor menu. <c>Menu.GetMenuItems</c> returns
/// the full (recursive) set of items under a given root — including ones Unity defines
/// natively — but it does not enumerate the roots themselves, and no internal API does. So we
/// union the native roots above with the first path segment of every <c>[MenuItem]</c> (which
/// surfaces custom/package roots), then expand each root through the internal API. The items
/// always come from the live menu, so native entries are included.
/// </summary>
static List<string> DiscoverMenuItems()
{
EnsureReflection();
if (s_GetMenuItems == null || s_GetPath == null)
throw new InvalidOperationException(
"UnityEditor.Menu.GetMenuItems is unavailable in this Unity version.");
var paths = new SortedSet<string>(StringComparer.Ordinal);
foreach (var root in CollectRoots())
CollectItemsUnder(root, paths);
return paths.ToList();
}
/// <summary>
/// The top-level menu names to expand: the native roots, plus the first segment of every
/// attributed menu item (covering custom/package menus). Component context menus
/// (<c>CONTEXT/…</c>) are excluded, as they are not part of the main menu bar.
/// </summary>
static IEnumerable<string> CollectRoots()
{
var roots = new SortedSet<string>(StringComparer.Ordinal);
foreach (var r in k_NativeRoots)
roots.Add(r);
foreach (var method in TypeCache.GetMethodsWithAttribute<MenuItem>())
{
foreach (var attr in method.GetCustomAttributes(typeof(MenuItem), false).Cast<MenuItem>())
{
if (attr.validate || string.IsNullOrEmpty(attr.menuItem))
continue;
var slash = attr.menuItem.IndexOf('/');
var root = slash > 0 ? attr.menuItem.Substring(0, slash) : attr.menuItem;
if (root.StartsWith("CONTEXT", StringComparison.Ordinal))
continue;
roots.Add(root);
}
}
return roots;
}
/// <summary>
/// Append every non-separator menu path under <paramref name="root"/> to
/// <paramref name="paths"/>. A root that isn't present in this Editor simply contributes
/// nothing (the API returns an empty array).
/// </summary>
static void CollectItemsUnder(string root, SortedSet<string> paths)
{
if (!(s_GetMenuItems.Invoke(null, new object[] { root, false, false }) is Array items))
return;
foreach (var item in items)
{
if (item == null)
continue;
if (s_GetIsSeparator != null && s_GetIsSeparator(item))
continue;
var path = s_GetPath(item);
if (string.IsNullOrEmpty(path))
continue;
paths.Add(path);
}
}
}
/// <summary>
/// Result of the <c>menu</c> command. On execution, <see cref="Success"/> false means the item
/// could not be invoked (not found or disabled). In list mode, <see cref="Items"/> holds the
/// available menu paths.
/// </summary>
[Serializable]
public class MenuResponse
{
[JsonProperty("success")]
public bool Success { get; set; }
[JsonProperty("path")]
public string Path { get; set; }
[JsonProperty("items")]
public List<string> Items { get; set; }
[JsonProperty("message")]
public string Message { get; set; }
public static MenuResponse Fail(string path, string message) => new MenuResponse
{
Success = false,
Path = path,
Message = message
};
}
}