Files
ft/Client/Packages/io.github.hatayama.uloopmcp/Editor/UI/Setup/SetupWizardWindow.cs

1199 lines
49 KiB
C#

using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;
using RuntimePlatform = UnityEngine.RuntimePlatform;
namespace io.github.hatayama.uLoopMCP
{
public class SetupWizardWindow : EditorWindow
{
private const string WindowTitle = "Unity CLI Loop Setup";
private const string UXML_RELATIVE_PATH = "Editor/UI/Setup/SetupWizardWindow.uxml";
private const string USS_RELATIVE_PATH = "Editor/UI/Setup/SetupWizardWindow.uss";
private const string GITHUB_ICON_RELATIVE_PATH = "Editor/UI/Setup/GitHub_Invertocat_White.png";
private const int PreferredWrappedTextLineCount = 2;
private const bool ForceFlatSkillInstall = true;
private static readonly Vector2 MinimumWindowSize = new(360f, 380f);
[InitializeOnLoadMethod]
private static void InitializeOnLoad()
{
if (AssetDatabase.IsAssetImportWorkerProcess()) return;
if (Application.isBatchMode) return;
TryShowOnVersionChange();
}
[MenuItem("Window/Unity CLI Loop/Setup Wizard", priority = 3)]
public static void ShowWindow()
{
ShowWindowInternal(false);
}
internal static bool ShouldAutoShowForVersion(
string currentVersion,
string lastSeenVersion,
bool suppressAutoShow)
{
if (suppressAutoShow) return false;
return !string.Equals(currentVersion, lastSeenVersion, System.StringComparison.Ordinal);
}
internal static void MaybeRecordLastSeenVersion(bool shouldRecordVersion, string version)
{
if (!shouldRecordVersion) return;
Debug.Assert(!string.IsNullOrEmpty(version), "version must not be null or empty");
McpEditorSettings.SetLastSeenSetupWizardVersion(version);
}
internal static void MaybeRecordSuppressedVersion(bool suppressAutoShow, string version)
{
if (!suppressAutoShow) return;
Debug.Assert(!string.IsNullOrEmpty(version), "version must not be null or empty");
McpEditorSettings.SetLastSeenSetupWizardVersion(version);
}
private static void TryShowOnVersionChange()
{
string currentVersion = McpConstants.PackageInfo.version;
bool suppressAutoShow = McpEditorSettings.GetSuppressSetupWizardAutoShow();
MaybeRecordSuppressedVersion(suppressAutoShow, currentVersion);
string lastSeenVersion = McpEditorSettings.GetLastSeenSetupWizardVersion();
if (!ShouldAutoShowForVersion(currentVersion, lastSeenVersion, suppressAutoShow)) return;
EditorApplication.delayCall += ShowWindowOnVersionChange;
}
private static void ShowWindowOnVersionChange()
{
ShowWindowInternal(true);
}
private static void ShowWindowInternal(bool shouldRecordVersion)
{
string currentVersion = McpConstants.PackageInfo.version;
if (TryReuseOpenWindow(
HasOpenInstances<SetupWizardWindow>(),
shouldRecordVersion,
currentVersion,
FocusExistingWindow))
{
return;
}
string lastSeenSetupWizardVersionBeforeOpen = McpEditorSettings.GetLastSeenSetupWizardVersion();
Rect windowPosition = CreateCenteredRect(EditorGUIUtility.GetMainWindowPosition(), MinimumWindowSize);
SetupWizardWindow window = CreateInstance<SetupWizardWindow>();
PrepareForOpen(window, WindowTitle, windowPosition, lastSeenSetupWizardVersionBeforeOpen);
window.ShowUtility();
window.ScheduleResizeToContent();
MaybeRecordLastSeenVersion(shouldRecordVersion, currentVersion);
}
internal static Rect WithContentSize(Rect currentRect, Vector2 contentSize, Vector2 frameSize)
{
Vector2 measuredSize = contentSize + frameSize;
Vector2 targetSize = new(
Mathf.Max(measuredSize.x, MinimumWindowSize.x),
Mathf.Max(measuredSize.y, MinimumWindowSize.y));
return CreateCenteredRect(currentRect, targetSize);
}
internal static Rect CreateCenteredRect(Rect bounds, Vector2 size)
{
Vector2 centeredPosition = bounds.center - (size * 0.5f);
return new Rect(centeredPosition, size);
}
internal static string GetGitHubRepositoryUrl()
{
return McpUIConstants.PROJECT_REPOSITORY_URL;
}
internal static bool TryReuseOpenWindow(
bool hasOpenWindow,
bool shouldRecordVersion,
string currentVersion,
System.Action focusExistingWindow)
{
if (!hasOpenWindow) return false;
Debug.Assert(focusExistingWindow != null, "focusExistingWindow must not be null");
Debug.Assert(!string.IsNullOrEmpty(currentVersion), "currentVersion must not be null or empty");
focusExistingWindow();
MaybeRecordLastSeenVersion(shouldRecordVersion, currentVersion);
return true;
}
internal static void PrepareForOpen(
SetupWizardWindow window,
string title,
Rect position,
string lastSeenSetupWizardVersionBeforeOpen)
{
Debug.Assert(window != null, "window must not be null");
Debug.Assert(!string.IsNullOrEmpty(title), "title must not be null or empty");
window.titleContent = new GUIContent(title);
window.position = position;
window._lastSeenSetupWizardVersionBeforeOpen =
lastSeenSetupWizardVersionBeforeOpen ?? string.Empty;
}
private static void FocusExistingWindow()
{
FocusWindowIfItsOpen<SetupWizardWindow>();
}
// Prerequisite
private VisualElement _nodejsWarning;
private VisualElement _nodejsOk;
private Button _refreshButton;
// Global CLI
private VisualElement _globalCliSection;
private VisualElement _cliStatusIcon;
private Label _cliStatusLabel;
private Button _installCliButton;
// Skills
private VisualElement _groupSkillsRow;
private VisualElement _projectCliVersionRow;
private VisualElement _skillsTargetRow;
private EnumField _skillsTargetField;
private Toggle _groupSkillsToggle;
private Label _groupSkillsLabel;
private Toggle _projectCliVersionToggle;
private VisualElement _skillsTargetList;
private VisualElement _skillsStatusDivider;
private Label _skillsStatusLabel;
private Button _installSkillsButton;
// Footer
private Toggle _suppressAutoShowToggle;
private Button _openSettingsButton;
private Button _closeButton;
private VisualElement _githubLinkRow;
private Label _githubLinkLabel;
private Image _githubLinkIcon;
private ScrollView _mainScrollView;
// State
private bool _isInstallingCli;
private bool _isInstallingSkills;
private bool _isApplyingContentSize;
private bool _isSkillsTargetFieldInitialized;
private bool _shouldUseFirstInstallSkillsUi;
private bool _installSkillsFlat;
[SerializeField]
private string _lastSeenSetupWizardVersionBeforeOpen = string.Empty;
private IVisualElementScheduledItem _initialRefreshScheduledItem;
private IVisualElementScheduledItem _resizeScheduledItem;
private CancellationTokenSource _skillInstallStateRefreshCts;
private SkillsTarget _skillsTarget = SkillsTarget.Claude;
private void CreateGUI()
{
InitializeFirstInstallSkillsUiState();
LoadLayout();
BindElements();
BindEvents();
BindSizeUpdates();
ApplyInitialCheckingState();
ScheduleInitialRefresh();
ScheduleResizeToContent();
}
private void InitializeFirstInstallSkillsUiState()
{
_shouldUseFirstInstallSkillsUi = ShouldUseFirstInstallSkillsUi(
_lastSeenSetupWizardVersionBeforeOpen);
}
private void OnDisable()
{
_initialRefreshScheduledItem?.Pause();
CancelSkillInstallStateRefresh();
}
private void LoadLayout()
{
string uxmlPath = $"{McpConstants.PackageAssetPath}/{UXML_RELATIVE_PATH}";
VisualTreeAsset visualTree = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(uxmlPath);
Debug.Assert(visualTree != null, $"UXML not found at {uxmlPath}");
visualTree.CloneTree(rootVisualElement);
string ussPath = $"{McpConstants.PackageAssetPath}/{USS_RELATIVE_PATH}";
StyleSheet styleSheet = AssetDatabase.LoadAssetAtPath<StyleSheet>(ussPath);
Debug.Assert(styleSheet != null, $"USS not found at {ussPath}");
rootVisualElement.styleSheets.Add(styleSheet);
}
private void BindElements()
{
_nodejsWarning = rootVisualElement.Q<VisualElement>("nodejs-warning");
_nodejsOk = rootVisualElement.Q<VisualElement>("nodejs-ok");
_refreshButton = rootVisualElement.Q<Button>("refresh-button");
_globalCliSection = rootVisualElement.Q<VisualElement>("step-1");
_cliStatusIcon = rootVisualElement.Q<VisualElement>("cli-status-icon");
_cliStatusLabel = rootVisualElement.Q<Label>("cli-status-label");
_installCliButton = rootVisualElement.Q<Button>("install-cli-button");
_groupSkillsRow = rootVisualElement.Q<VisualElement>("group-skills-row");
_projectCliVersionRow = rootVisualElement.Q<VisualElement>("project-cli-version-row");
_skillsTargetRow = rootVisualElement.Q<VisualElement>("skills-target-row");
_skillsTargetField = rootVisualElement.Q<EnumField>("skills-target-field");
_groupSkillsToggle = rootVisualElement.Q<Toggle>("group-skills-toggle");
_groupSkillsLabel = rootVisualElement.Q<Label>("group-skills-label");
_projectCliVersionToggle = rootVisualElement.Q<Toggle>("project-cli-version-toggle");
_skillsTargetList = rootVisualElement.Q<VisualElement>("skills-target-list");
_skillsStatusDivider = rootVisualElement.Q<VisualElement>("skills-status-divider");
_skillsStatusLabel = rootVisualElement.Q<Label>("skills-status-label");
_installSkillsButton = rootVisualElement.Q<Button>("install-skills-button");
_suppressAutoShowToggle = rootVisualElement.Q<Toggle>("suppress-auto-show-toggle");
_openSettingsButton = rootVisualElement.Q<Button>("open-settings-button");
_closeButton = rootVisualElement.Q<Button>("close-button");
_githubLinkRow = rootVisualElement.Q<VisualElement>("github-link-row");
_githubLinkLabel = rootVisualElement.Q<Label>("github-link-label");
_githubLinkIcon = rootVisualElement.Q<Image>("github-link-icon");
_mainScrollView = rootVisualElement.Q<ScrollView>();
}
private void BindEvents()
{
_refreshButton.clicked += () => RefreshUI();
_installCliButton.clicked += HandleInstallCli;
_installSkillsButton.clicked += HandleInstallSkills;
InitializeSkillsTargetField();
InitializeGroupSkillsToggle();
InitializeProjectCliVersionToggle();
_suppressAutoShowToggle.RegisterValueChangedCallback(evt => HandleSuppressAutoShowChanged(evt.newValue));
_openSettingsButton.clicked += HandleOpenSettings;
_closeButton.clicked += HandleClose;
_githubLinkRow.RegisterCallback<ClickEvent>(_ => HandleOpenGitHub());
_githubLinkRow.RegisterCallback<MouseEnterEvent>(_ => HandleGitHubHoverChanged(true));
_githubLinkRow.RegisterCallback<MouseLeaveEvent>(_ => HandleGitHubHoverChanged(false));
ConfigureScrollView();
InitializeGitHubIcon();
}
private void ConfigureScrollView()
{
Debug.Assert(_mainScrollView != null, "mainScrollView must not be null");
_mainScrollView.verticalScrollerVisibility = ScrollerVisibility.Auto;
_mainScrollView.horizontalScrollerVisibility = ScrollerVisibility.Hidden;
}
private void InitializeGitHubIcon()
{
string iconPath = $"{McpConstants.PackageAssetPath}/{GITHUB_ICON_RELATIVE_PATH}";
Texture2D iconTexture = AssetDatabase.LoadAssetAtPath<Texture2D>(iconPath);
Debug.Assert(iconTexture != null, $"GitHub icon not found at {iconPath}");
_githubLinkIcon.image = iconTexture;
}
private void InitializeSkillsTargetField()
{
if (_isSkillsTargetFieldInitialized) return;
_skillsTargetField.Init(_skillsTarget);
_skillsTargetField.RegisterValueChangedCallback(evt =>
{
if (evt.newValue is SkillsTarget newTarget)
{
_skillsTarget = newTarget;
RefreshSkillsSection();
}
});
_isSkillsTargetFieldInitialized = true;
}
private void RefreshSkillsTargetField()
{
Debug.Assert(_isSkillsTargetFieldInitialized, "skills target field must be initialized");
_skillsTargetField.SetValueWithoutNotify(_skillsTarget);
}
private void InitializeGroupSkillsToggle()
{
ApplyFlatSkillInstallPreference();
ViewDataBinder.SetVisible(_groupSkillsRow, false);
_groupSkillsToggle.SetValueWithoutNotify(!_installSkillsFlat);
_groupSkillsToggle.RegisterValueChangedCallback(evt =>
{
evt.StopPropagation();
ApplyFlatSkillInstallPreference();
RefreshSkillsSection();
});
_groupSkillsLabel.RegisterCallback<ClickEvent>(HandleGroupSkillsRowClicked);
}
private void InitializeProjectCliVersionToggle()
{
RefreshProjectCliVersionToggle();
_projectCliVersionToggle.RegisterValueChangedCallback(evt =>
{
evt.StopPropagation();
HandleProjectCliVersionChanged(evt.newValue);
});
_projectCliVersionRow.RegisterCallback<ClickEvent>(HandleProjectCliVersionRowClicked);
}
private void RefreshProjectCliVersionToggle()
{
_projectCliVersionToggle.SetValueWithoutNotify(IsProjectCliVersionEnabled());
}
private static bool IsProjectCliVersionEnabled()
{
return ToolSettings.GetSkillCliInvocation() == CliConstants.SKILL_CLI_INVOCATION_NPX;
}
private void UpdateGlobalCliSectionVisibility()
{
ViewDataBinder.SetVisible(_globalCliSection, !IsProjectCliVersionEnabled());
}
private void BindSizeUpdates()
{
rootVisualElement.RegisterCallback<GeometryChangedEvent>(_ =>
{
if (_isApplyingContentSize) return;
ScheduleResizeToContent();
});
}
private void RefreshAutoShowToggle()
{
_suppressAutoShowToggle.SetValueWithoutNotify(McpEditorSettings.GetSuppressSetupWizardAutoShow());
}
private void ApplyInitialCheckingState()
{
RefreshAutoShowToggle();
ViewDataBinder.SetVisible(_nodejsWarning, false);
ViewDataBinder.SetVisible(_nodejsOk, false);
ViewDataBinder.ToggleClass(_cliStatusIcon, "setup-status-icon--success", false);
ViewDataBinder.ToggleClass(_cliStatusIcon, "setup-status-icon--pending", true);
_cliStatusLabel.text = "Checking...";
_installCliButton.SetEnabled(false);
_installCliButton.text = "Checking...";
ViewDataBinder.SetVisible(_groupSkillsRow, false);
_groupSkillsToggle.SetEnabled(false);
RefreshProjectCliVersionToggle();
UpdateGlobalCliSectionVisibility();
_projectCliVersionToggle.SetEnabled(false);
UpdateSkillsStatusLabel("Checking installed skills...");
_installSkillsButton.SetEnabled(false);
_installSkillsButton.text = "Checking...";
ViewDataBinder.SetVisible(_skillsTargetRow, _shouldUseFirstInstallSkillsUi);
ViewDataBinder.SetVisible(_skillsTargetList, !_shouldUseFirstInstallSkillsUi);
_skillsTargetList.Clear();
}
private void UpdateSkillsStatusLabel(string text)
{
_skillsStatusLabel.text = text;
bool isVisible = !string.IsNullOrEmpty(text);
ViewDataBinder.SetVisible(_skillsStatusDivider, isVisible);
ViewDataBinder.SetVisible(_skillsStatusLabel, isVisible);
}
private void ScheduleInitialRefresh()
{
_initialRefreshScheduledItem?.Pause();
_initialRefreshScheduledItem = rootVisualElement.schedule.Execute(() => RefreshUI()).StartingIn(0);
}
private void RefreshSkillsSection()
{
string cachedCliVersion = CliInstallationDetector.GetCachedCliVersion();
bool cliInstalled = !string.IsNullOrEmpty(cachedCliVersion);
string projectRoot = UnityMcpPathResolver.GetProjectRoot();
List<ToolSkillSynchronizer.SkillTargetInfo> targets = DetectDisplayedSkillTargetsFast(projectRoot);
RefreshProjectCliVersionToggle();
UpdateGlobalCliSectionVisibility();
_projectCliVersionToggle.SetEnabled(!_isInstallingCli && !_isInstallingSkills);
bool canManageSkills = CanManageSkills(cliInstalled, IsProjectCliVersionEnabled()) && !_isInstallingCli;
UpdateSkillsStep(canManageSkills, targets);
BeginRefreshDisplayedSkillTargets(canManageSkills);
ScheduleResizeToContent();
}
private async void RefreshUI(bool refreshSkillsSection = true)
{
CancelSkillInstallStateRefresh();
RefreshAutoShowToggle();
ViewDataBinder.SetVisible(_nodejsWarning, false);
ViewDataBinder.SetVisible(_nodejsOk, false);
ViewDataBinder.ToggleClass(_cliStatusIcon, "setup-status-icon--success", false);
ViewDataBinder.ToggleClass(_cliStatusIcon, "setup-status-icon--pending", true);
_cliStatusLabel.text = "Checking...";
_installCliButton.SetEnabled(false);
_installCliButton.text = "Checking...";
if (refreshSkillsSection)
{
ViewDataBinder.SetVisible(_groupSkillsRow, false);
_groupSkillsToggle.SetEnabled(false);
RefreshProjectCliVersionToggle();
UpdateGlobalCliSectionVisibility();
_projectCliVersionToggle.SetEnabled(false);
UpdateSkillsStatusLabel("Checking installed skills...");
_installSkillsButton.SetEnabled(false);
_installSkillsButton.text = "Checking...";
ViewDataBinder.SetVisible(_skillsTargetRow, _shouldUseFirstInstallSkillsUi);
ViewDataBinder.SetVisible(_skillsTargetList, !_shouldUseFirstInstallSkillsUi);
_skillsTargetList.Clear();
}
await Task.Yield();
RuntimePlatform platform = Application.platform;
string nodePath = await Task.Run(() => NodeEnvironmentResolver.FindNodePathAtPlatform(platform));
bool nodeDetected = !string.IsNullOrEmpty(nodePath);
ViewDataBinder.SetVisible(_nodejsWarning, !nodeDetected);
ViewDataBinder.SetVisible(_nodejsOk, nodeDetected);
if (!nodeDetected)
{
_cliStatusLabel.text = "Requires Node.js";
ViewDataBinder.ToggleClass(_cliStatusIcon, "setup-status-icon--success", false);
ViewDataBinder.ToggleClass(_cliStatusIcon, "setup-status-icon--pending", true);
_installCliButton.SetEnabled(false);
_installSkillsButton.SetEnabled(false);
_groupSkillsToggle.SetEnabled(false);
_projectCliVersionToggle.SetEnabled(false);
UpdateSkillsStatusLabel(string.Empty);
_skillsTargetList.Clear();
ScheduleResizeToContent();
return;
}
await CliInstallationDetector.ForceRefreshCliVersionAsync(CancellationToken.None);
string cliVersion = CliInstallationDetector.GetCachedCliVersion();
bool cliInstalled = cliVersion != null;
bool cliVersionMatched = IsCliVersionMatched(cliVersion);
UpdateCliStep(cliInstalled, cliVersion, cliVersionMatched);
RefreshProjectCliVersionToggle();
UpdateGlobalCliSectionVisibility();
_projectCliVersionToggle.SetEnabled(!_isInstallingCli && !_isInstallingSkills);
if (!refreshSkillsSection)
{
ScheduleResizeToContent();
return;
}
string projectRoot = UnityMcpPathResolver.GetProjectRoot();
List<ToolSkillSynchronizer.SkillTargetInfo> targets = DetectDisplayedSkillTargetsFast(projectRoot);
bool canManageSkills = CanManageSkills(cliInstalled, IsProjectCliVersionEnabled())
&& !_isInstallingCli;
UpdateSkillsStep(canManageSkills, targets);
BeginRefreshDisplayedSkillTargets(canManageSkills);
ScheduleResizeToContent();
}
private List<ToolSkillSynchronizer.SkillTargetInfo> DetectDisplayedSkillTargets(string projectRoot)
{
return ToolSkillSynchronizer.DetectTargetsForLayoutAtProjectRoot(projectRoot, !_installSkillsFlat);
}
private List<ToolSkillSynchronizer.SkillTargetInfo> DetectDisplayedSkillTargetsFast(string projectRoot)
{
return ToolSkillSynchronizer.DetectTargetsForLayoutFastAtProjectRoot(projectRoot, !_installSkillsFlat);
}
private void BeginRefreshDisplayedSkillTargets(bool canManageSkills)
{
CancelSkillInstallStateRefresh();
if (!canManageSkills || _isInstallingSkills)
{
return;
}
CancellationTokenSource cts = new();
_skillInstallStateRefreshCts = cts;
RefreshDisplayedSkillTargetsAsync(cts.Token);
}
private async void RefreshDisplayedSkillTargetsAsync(CancellationToken ct)
{
string projectRoot = UnityMcpPathResolver.GetProjectRoot();
List<ToolSkillSynchronizer.SkillTargetInfo> targets =
await Task.Run(() => DetectDisplayedSkillTargets(projectRoot));
if (ct.IsCancellationRequested)
{
return;
}
UpdateSkillsStep(canManageSkills: true, targets);
ScheduleResizeToContent();
}
private void CancelSkillInstallStateRefresh()
{
if (_skillInstallStateRefreshCts == null)
{
return;
}
_skillInstallStateRefreshCts.Cancel();
_skillInstallStateRefreshCts.Dispose();
_skillInstallStateRefreshCts = null;
}
internal static List<ToolSkillSynchronizer.SkillTargetInfo> FilterInstallableSkillTargets(
IEnumerable<ToolSkillSynchronizer.SkillTargetInfo> targets)
{
Debug.Assert(targets != null, "targets must not be null");
return targets
.Where(target => target.HasSkillsDirectory)
.ToList();
}
internal static bool ShouldUseFirstInstallSkillsUi(string lastSeenSetupWizardVersion)
{
return string.IsNullOrEmpty(lastSeenSetupWizardVersion);
}
internal static bool ShouldUseTargetSelectionSkillsUi(
bool shouldUseFirstInstallSkillsUi,
int installableTargetCount)
{
Debug.Assert(installableTargetCount >= 0, "installableTargetCount must not be negative");
return shouldUseFirstInstallSkillsUi || installableTargetCount == 0;
}
internal static bool CanManageSkills(bool cliInstalled, bool useProjectCliVersion)
{
return cliInstalled || useProjectCliVersion;
}
internal static ToolSkillSynchronizer.SkillTargetInfo CreateFirstInstallSkillTarget(
SkillsTarget target,
bool groupSkillsUnderUnityCliLoop)
{
SkillsTargetSelection selection = SkillsTargetSelectionResolver.Resolve(
target,
groupSkillsUnderUnityCliLoop);
return new(
selection.DisplayName,
selection.DirectoryName,
selection.InstallFlag,
hasSkillsDirectory: false,
hasExistingSkills: false);
}
internal static ToolSkillSynchronizer.SkillTargetInfo GetSelectedSkillTargetInfo(
IEnumerable<ToolSkillSynchronizer.SkillTargetInfo> targets,
SkillsTarget target,
bool groupSkillsUnderUnityCliLoop)
{
Debug.Assert(targets != null, "targets must not be null");
SkillsTargetSelection selection = SkillsTargetSelectionResolver.Resolve(
target,
groupSkillsUnderUnityCliLoop);
ToolSkillSynchronizer.SkillTargetInfo selectedTargetInfo = targets
.FirstOrDefault(info => info.DirName == selection.DirectoryName);
return string.IsNullOrEmpty(selectedTargetInfo.DirName)
? CreateFirstInstallSkillTarget(target, groupSkillsUnderUnityCliLoop)
: selectedTargetInfo;
}
internal static List<ToolSkillSynchronizer.SkillTargetInfo> GetFirstInstallableSkillTargets(
IEnumerable<ToolSkillSynchronizer.SkillTargetInfo> targets,
SkillsTarget target,
bool groupSkillsUnderUnityCliLoop)
{
ToolSkillSynchronizer.SkillTargetInfo selectedTargetInfo = GetSelectedSkillTargetInfo(
targets,
target,
groupSkillsUnderUnityCliLoop);
return selectedTargetInfo.InstallState == SkillInstallState.Installed
|| selectedTargetInfo.InstallState == SkillInstallState.Checking
? new List<ToolSkillSynchronizer.SkillTargetInfo>()
: new List<ToolSkillSynchronizer.SkillTargetInfo> { selectedTargetInfo };
}
internal static List<ToolSkillSynchronizer.SkillTargetInfo> GetSetupWizardInstallableSkillTargets(
IEnumerable<ToolSkillSynchronizer.SkillTargetInfo> targets,
SkillsTarget target,
bool groupSkillsUnderUnityCliLoop,
bool shouldUseFirstInstallSkillsUi)
{
List<ToolSkillSynchronizer.SkillTargetInfo> installableTargets =
FilterInstallableSkillTargets(targets);
if (!ShouldUseTargetSelectionSkillsUi(
shouldUseFirstInstallSkillsUi,
installableTargets.Count))
{
return installableTargets;
}
return GetFirstInstallableSkillTargets(
targets,
target,
groupSkillsUnderUnityCliLoop);
}
private void UpdateCliStep(bool cliInstalled, string cliVersion, bool cliVersionMatched)
{
if (cliInstalled && cliVersionMatched)
{
_cliStatusLabel.text = $"v{cliVersion}";
ViewDataBinder.ToggleClass(_cliStatusIcon, "setup-status-icon--success", true);
ViewDataBinder.ToggleClass(_cliStatusIcon, "setup-status-icon--pending", false);
_installCliButton.SetEnabled(false);
_installCliButton.text = "Installed";
return;
}
if (cliInstalled)
{
string requiredVersion = McpConstants.PackageInfo.version;
_cliStatusLabel.text = $"v{cliVersion} (requires v{requiredVersion})";
}
else
{
_cliStatusLabel.text = "Not installed";
}
ViewDataBinder.ToggleClass(_cliStatusIcon, "setup-status-icon--success", false);
ViewDataBinder.ToggleClass(_cliStatusIcon, "setup-status-icon--pending", true);
_installCliButton.SetEnabled(!_isInstallingCli);
_installCliButton.text = _isInstallingCli ? "Installing..." : "Install CLI";
}
private static bool IsCliVersionMatched(string cliVersion)
{
return CliVersionComparison.IsSameVersion(cliVersion, McpConstants.PackageInfo.version);
}
private void UpdateSkillsStep(
bool canManageSkills,
List<ToolSkillSynchronizer.SkillTargetInfo> targets)
{
_skillsTargetList.Clear();
if (!canManageSkills)
{
UpdateSkillsStatusLabel(string.Empty);
_installSkillsButton.SetEnabled(false);
_installSkillsButton.text = GetSkillsButtonTextForSetupWizard(
cliInstalled: false,
_isInstallingSkills,
hasOutdatedSkills: false);
_groupSkillsToggle.SetEnabled(false);
RefreshProjectCliVersionToggle();
_projectCliVersionToggle.SetEnabled(!_isInstallingCli && !_isInstallingSkills);
ViewDataBinder.SetVisible(_skillsTargetRow, false);
ViewDataBinder.SetVisible(_skillsTargetList, false);
return;
}
_groupSkillsToggle.SetEnabled(!_isInstallingSkills);
RefreshProjectCliVersionToggle();
_projectCliVersionToggle.SetEnabled(!_isInstallingSkills);
List<ToolSkillSynchronizer.SkillTargetInfo> installableTargets = FilterInstallableSkillTargets(targets);
bool useTargetSelectionSkillsUi = ShouldUseTargetSelectionSkillsUi(
_shouldUseFirstInstallSkillsUi,
installableTargets.Count);
ViewDataBinder.SetVisible(_skillsTargetRow, useTargetSelectionSkillsUi);
ViewDataBinder.SetVisible(_skillsTargetList, !useTargetSelectionSkillsUi);
if (useTargetSelectionSkillsUi)
{
RefreshSkillsTargetField();
ToolSkillSynchronizer.SkillTargetInfo selectedTargetInfo = GetSelectedSkillTargetInfo(
targets,
_skillsTarget,
!_installSkillsFlat);
UpdateSkillsStatusLabel(string.Empty);
_installSkillsButton.text = CliSetupSection.GetInstallSkillsButtonText(
isCliInstalled: true,
_isInstallingSkills,
selectedTargetInfo.InstallState);
_installSkillsButton.SetEnabled(CliSetupSection.IsInstallSkillsButtonEnabled(
isCliInstalled: true,
_isInstallingSkills,
isChecking: false,
selectedTargetInfo.InstallState));
return;
}
foreach (ToolSkillSynchronizer.SkillTargetInfo target in installableTargets)
{
VisualElement item = new VisualElement();
item.AddToClassList("setup-target-item");
Label nameLabel = new Label($"{target.DisplayName} ({target.DirName}/)");
nameLabel.AddToClassList("setup-target-item__label");
item.Add(nameLabel);
Label statusLabel = new Label(GetSkillInstallStatusText(
target.InstallState,
target.HasDifferentLayoutSkills,
!_installSkillsFlat));
statusLabel.AddToClassList("setup-target-item__status");
statusLabel.AddToClassList(GetSkillInstallStatusClass(
target.InstallState,
target.HasDifferentLayoutSkills,
!_installSkillsFlat));
item.Add(statusLabel);
_skillsTargetList.Add(item);
}
bool isCheckingSkills = installableTargets.Any(
t => t.InstallState == SkillInstallState.Checking);
if (isCheckingSkills)
{
UpdateSkillsStatusLabel("Checking installed skills...");
_installSkillsButton.SetEnabled(false);
_installSkillsButton.text = "Checking...";
return;
}
bool allSkillsInstalled = installableTargets.All(
t => t.InstallState == SkillInstallState.Installed);
if (allSkillsInstalled)
{
UpdateSkillsStatusLabel($"Installed for {installableTargets.Count} targets");
_installSkillsButton.SetEnabled(false);
_installSkillsButton.text = "Installed";
}
else
{
bool hasOutdatedSkills = installableTargets.Any(
t => t.InstallState == SkillInstallState.Outdated);
UpdateSkillsStatusLabel(string.Empty);
_installSkillsButton.SetEnabled(!_isInstallingSkills);
_installSkillsButton.text = GetSkillsButtonTextForSetupWizard(
cliInstalled: true,
_isInstallingSkills,
hasOutdatedSkills);
}
}
internal static string GetSkillsButtonTextForSetupWizard(
bool cliInstalled,
bool isInstallingSkills,
bool hasOutdatedSkills)
{
return !cliInstalled
? "Install Skills"
: GetInstallSkillsButtonText(isInstallingSkills, hasOutdatedSkills);
}
internal static string GetInstallSkillsButtonText(
bool isInstallingSkills,
bool hasOutdatedSkills)
{
if (isInstallingSkills)
{
return "Installing...";
}
return hasOutdatedSkills ? "Update Skills" : "Install Skills";
}
internal static string GetSkillInstallStatusText(
SkillInstallState installState,
bool hasDifferentLayoutSkills,
bool groupSkillsUnderUnityCliLoop)
{
if (installState == SkillInstallState.Checking)
{
return "Checking...";
}
if (installState == SkillInstallState.Installed)
{
return "Installed";
}
if (installState == SkillInstallState.Outdated)
{
return "Outdated";
}
if (!hasDifferentLayoutSkills)
{
return "Missing";
}
return groupSkillsUnderUnityCliLoop ? "Not grouped" : "Grouped";
}
internal static string GetSkillInstallStatusClass(
SkillInstallState installState,
bool hasDifferentLayoutSkills,
bool groupSkillsUnderUnityCliLoop)
{
if (installState == SkillInstallState.Checking)
{
return "setup-target-item__status--checking";
}
if (installState == SkillInstallState.Installed)
{
return "setup-target-item__status--installed";
}
if (installState == SkillInstallState.Outdated)
{
return "setup-target-item__status--outdated";
}
if (!hasDifferentLayoutSkills)
{
return "setup-target-item__status--missing";
}
return groupSkillsUnderUnityCliLoop
? "setup-target-item__status--different-layout"
: "setup-target-item__status--different-layout";
}
private async void HandleInstallCli()
{
bool wasCliInstalledBeforeInstall = CliInstallationDetector.IsCliInstalled();
string npmPath = NodeEnvironmentResolver.FindNpmPath();
if (string.IsNullOrEmpty(npmPath))
{
EditorUtility.DisplayDialog(
"npm Not Found",
"npm was not found on this system.\nPlease install Node.js first, then try again.",
"OK");
return;
}
string packageVersion = McpConstants.PackageInfo.version;
string installTarget = $"{CliConstants.NPM_PACKAGE_NAME}@{packageVersion}";
bool permissionOk = CliInstaller.CheckWindowsPermissions(
npmPath, installTarget, out string globalPrefix, out string manualCommand);
if (!permissionOk)
{
EditorUtility.DisplayDialog(
"Permission Issue",
$"npm's global directory ({globalPrefix}) requires elevated permissions.\n\n"
+ NpmInstallDiagnostics.BuildPermissionSolutions(manualCommand),
"OK");
return;
}
_isInstallingCli = true;
CancelSkillInstallStateRefresh();
UpdateCliStep(false, null, false);
RefreshSkillsSection();
try
{
string nodePath = NodeEnvironmentResolver.FindNodePath();
CliInstallResult result = await CliInstaller.InstallAsync(npmPath, installTarget, nodePath);
if (!result.Success)
{
EditorUtility.DisplayDialog(
"Installation Failed",
$"Failed to install uloop-cli.\n\n{result.ErrorOutput}\n\n"
+ $"You can install manually:\n npm install -g {installTarget}",
"OK");
}
}
finally
{
_isInstallingCli = false;
RefreshUI(CliInstallRefreshPolicy.ShouldRefreshSkillsAfterCliInstall(
wasCliInstalledBeforeInstall));
}
}
private async void HandleInstallSkills()
{
CancelSkillInstallStateRefresh();
string projectRoot = UnityMcpPathResolver.GetProjectRoot();
List<ToolSkillSynchronizer.SkillTargetInfo> targets = DetectDisplayedSkillTargets(projectRoot);
List<ToolSkillSynchronizer.SkillTargetInfo> installableTargets =
GetSetupWizardInstallableSkillTargets(
targets,
_skillsTarget,
!_installSkillsFlat,
_shouldUseFirstInstallSkillsUi);
if (installableTargets.Count == 0) return;
_isInstallingSkills = true;
UpdateSkillsStep(true, targets);
try
{
await ToolSkillSynchronizer.InstallSkillFiles(
installableTargets,
!_installSkillsFlat);
EditorDialogHelper.ShowSkillsInstalledDialog();
}
finally
{
_isInstallingSkills = false;
RefreshSkillsSection();
}
}
private void HandleOpenSettings()
{
McpEditorWindow.ShowWindow();
}
private void HandleSuppressAutoShowChanged(bool suppressAutoShow)
{
McpEditorSettings.SetSuppressSetupWizardAutoShow(suppressAutoShow);
MaybeRecordSuppressedVersion(suppressAutoShow, McpConstants.PackageInfo.version);
ScheduleResizeToContent();
}
private void HandleClose()
{
Close();
}
private void HandleOpenGitHub()
{
Application.OpenURL(GetGitHubRepositoryUrl());
}
private void HandleGitHubHoverChanged(bool isHovered)
{
ViewDataBinder.ToggleClass(_githubLinkRow, "setup-footer__github-link--hover", isHovered);
ViewDataBinder.ToggleClass(_githubLinkLabel, "setup-footer__github-link-label--hover", isHovered);
ViewDataBinder.ToggleClass(_githubLinkIcon, "setup-footer__github-link-icon--hover", isHovered);
}
private void HandleGroupSkillsRowClicked(ClickEvent evt)
{
evt.StopPropagation();
if (!_groupSkillsToggle.enabledSelf)
{
return;
}
if (evt.target is VisualElement targetElement && _groupSkillsToggle.Contains(targetElement))
{
return;
}
bool newValue = !_groupSkillsToggle.value;
_groupSkillsToggle.SetValueWithoutNotify(newValue);
ApplyFlatSkillInstallPreference();
RefreshSkillsSection();
}
private void HandleProjectCliVersionRowClicked(ClickEvent evt)
{
evt.StopPropagation();
if (!_projectCliVersionToggle.enabledSelf)
{
return;
}
if (evt.target is VisualElement targetElement && _projectCliVersionToggle.Contains(targetElement))
{
return;
}
bool newValue = !_projectCliVersionToggle.value;
_projectCliVersionToggle.SetValueWithoutNotify(newValue);
HandleProjectCliVersionChanged(newValue);
}
private void HandleProjectCliVersionChanged(bool useProjectCliVersion)
{
string invocation = useProjectCliVersion
? CliConstants.SKILL_CLI_INVOCATION_NPX
: CliConstants.SKILL_CLI_INVOCATION_GLOBAL;
ToolSettings.SetSkillCliInvocation(invocation);
RefreshProjectCliVersionToggle();
UpdateGlobalCliSectionVisibility();
RefreshSkillsSection();
}
private void ApplyFlatSkillInstallPreference()
{
// Claude Code does not resolve nested skill folders, so setup keeps every editor target on the flat layout.
_installSkillsFlat = ForceFlatSkillInstall;
McpEditorSettings.SetInstallSkillsFlat(_installSkillsFlat);
}
private void ScheduleResizeToContent()
{
_resizeScheduledItem?.Pause();
_resizeScheduledItem = rootVisualElement.schedule.Execute(ResizeToContent).StartingIn(0);
}
private void ResizeToContent()
{
ScrollView mainContainer = rootVisualElement.Q<ScrollView>();
if (mainContainer == null) return;
if (rootVisualElement.layout.width <= 0f || rootVisualElement.layout.height <= 0f) return;
Vector2 contentSize = MeasureContentSize(mainContainer);
if (!HasFiniteSize(contentSize)) return;
if (contentSize.x <= 0f || contentSize.y <= 0f) return;
Vector2 frameSize = position.size - rootVisualElement.layout.size;
if (!HasFiniteSize(frameSize)) return;
Rect targetRect = WithContentSize(position, contentSize, frameSize);
if (!HasFiniteSize(targetRect.size)) return;
if (Approximately(position.size, targetRect.size))
{
minSize = targetRect.size;
maxSize = targetRect.size;
return;
}
_isApplyingContentSize = true;
minSize = targetRect.size;
maxSize = targetRect.size;
position = targetRect;
_isApplyingContentSize = false;
}
private static Vector2 MeasureContentSize(ScrollView mainContainer)
{
VisualElement contentContainer = mainContainer.contentContainer;
float width = MeasurePreferredContentWidth(mainContainer, contentContainer);
float height = MeasurePreferredContentHeight(mainContainer, contentContainer);
return new Vector2(width, height);
}
private static float MeasurePreferredContentWidth(VisualElement mainContainer, VisualElement contentContainer)
{
float maxRight = 0f;
foreach (TextElement textElement in contentContainer.Query<TextElement>().Build())
{
if (!textElement.visible) continue;
if (string.IsNullOrEmpty(textElement.text)) continue;
if (!HasFiniteRect(textElement.worldBound)) continue;
float left = textElement.worldBound.xMin - contentContainer.worldBound.xMin;
float horizontalChrome =
textElement.resolvedStyle.paddingLeft
+ textElement.resolvedStyle.paddingRight
+ textElement.resolvedStyle.borderLeftWidth
+ textElement.resolvedStyle.borderRightWidth;
float verticalChrome =
textElement.resolvedStyle.paddingTop
+ textElement.resolvedStyle.paddingBottom
+ textElement.resolvedStyle.borderTopWidth
+ textElement.resolvedStyle.borderBottomWidth;
float laidOutWidth = textElement.worldBound.width;
Vector2 measuredTextSize = textElement.MeasureTextSize(
textElement.text,
0f,
VisualElement.MeasureMode.Undefined,
0f,
VisualElement.MeasureMode.Undefined);
if (!IsFinite(left)) continue;
if (!IsFinite(horizontalChrome) || !IsFinite(verticalChrome)) continue;
if (!HasFiniteSize(measuredTextSize)) continue;
if (!IsFinite(laidOutWidth)) continue;
float measuredWidth = measuredTextSize.x + horizontalChrome;
int lineCount = EstimateWrappedLineCount(
textElement.worldBound.height - verticalChrome,
measuredTextSize.y);
float preferredWidth = SelectPreferredTextWidth(
laidOutWidth,
measuredWidth,
lineCount,
textElement.resolvedStyle.whiteSpace);
if (!IsFinite(preferredWidth)) continue;
float right = left + preferredWidth;
maxRight = Mathf.Max(maxRight, right);
}
float width =
mainContainer.resolvedStyle.paddingLeft
+ maxRight
+ mainContainer.resolvedStyle.paddingRight;
return IsFinite(width) ? Mathf.Ceil(width) : 0f;
}
internal static int EstimateWrappedLineCount(float laidOutTextHeight, float singleLineTextHeight)
{
if (singleLineTextHeight <= 0f) return 1;
return Mathf.Max(1, Mathf.RoundToInt(laidOutTextHeight / singleLineTextHeight));
}
internal static float SelectPreferredTextWidth(
float laidOutWidth,
float measuredWidth,
int lineCount,
WhiteSpace whiteSpace)
{
if (whiteSpace != WhiteSpace.Normal) return measuredWidth;
if (lineCount <= PreferredWrappedTextLineCount) return Mathf.Min(laidOutWidth, measuredWidth);
return Mathf.Max(laidOutWidth, measuredWidth / PreferredWrappedTextLineCount);
}
private static float MeasurePreferredContentHeight(VisualElement mainContainer, VisualElement contentContainer)
{
float maxBottom = 0f;
foreach (VisualElement child in contentContainer.Children())
{
if (!child.visible) continue;
if (!HasFiniteRect(child.worldBound)) continue;
float bottom = child.worldBound.yMax - contentContainer.worldBound.yMin;
if (!IsFinite(bottom)) continue;
maxBottom = Mathf.Max(maxBottom, bottom);
}
float height =
mainContainer.resolvedStyle.paddingTop
+ maxBottom
+ mainContainer.resolvedStyle.paddingBottom;
return IsFinite(height) ? Mathf.Ceil(height) : 0f;
}
private static bool Approximately(Vector2 left, Vector2 right)
{
const float Tolerance = 0.5f;
return Mathf.Abs(left.x - right.x) < Tolerance && Mathf.Abs(left.y - right.y) < Tolerance;
}
internal static bool HasFiniteSize(Vector2 size)
{
return IsFinite(size.x) && IsFinite(size.y);
}
private static bool HasFiniteRect(Rect rect)
{
return IsFinite(rect.xMin)
&& IsFinite(rect.xMax)
&& IsFinite(rect.yMin)
&& IsFinite(rect.yMax);
}
private static bool IsFinite(float value)
{
return !float.IsNaN(value) && !float.IsInfinity(value);
}
}
}