Files
ft/Client/Packages/io.github.hatayama.uloopmcp/Editor/UI/UIToolkit/Components/ToolSettingsSection.cs

563 lines
20 KiB
C#

using System;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
using UnityEngine.UIElements;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// UI section for tool permissions and a virtualized per-tool enable list.
/// The expensive list is allowed to stay unloaded while the foldout is collapsed.
/// </summary>
public class ToolSettingsSection
{
private const int ToolListRowHeight = 24;
private const int InlineToolRowLimit = 40;
private readonly Foldout _foldout;
private readonly Toggle _allowThirdPartyToggle;
private readonly Label _allowThirdPartyLabel;
private readonly Button _securityLevelRestrictedButton;
private readonly Button _securityLevelFullAccessButton;
private readonly Label _securityLevelDescription;
private readonly VisualElement _toolSettingsInfoContainer;
private readonly VisualElement _toolListContainer;
private readonly Label _toolListStatusLabel;
private readonly ListView _toolListView;
private readonly List<ToolListRowData> _toolListRows = new();
private readonly Dictionary<string, Toggle> _togglesByToolName = new();
private bool _allowThirdPartyTools = true;
private bool _isRegistryAvailable;
private bool _isUnavailableStateShown;
private bool _isLoadingStateShown;
private string _layoutSignature = string.Empty;
public event Action<bool> OnFoldoutChanged;
public event Action<string, bool> OnToolToggled;
public event Action<bool> OnAllowThirdPartyChanged;
public event Action<DynamicCodeSecurityLevel> OnSecurityLevelChanged;
public ToolSettingsSection(VisualElement root)
{
_foldout = root.Q<Foldout>("tool-settings-foldout");
_allowThirdPartyToggle = root.Q<Toggle>("allow-third-party-toggle");
_allowThirdPartyLabel = root.Q<Label>("allow-third-party-label");
_securityLevelRestrictedButton = root.Q<Button>("security-level-restricted-button");
_securityLevelFullAccessButton = root.Q<Button>("security-level-full-access-button");
_securityLevelDescription = root.Q<Label>("security-level-description");
_toolSettingsInfoContainer = root.Q<VisualElement>("tool-settings-info-container");
_toolListContainer = root.Q<VisualElement>("tool-list-container");
Debug.Assert(_toolListContainer != null, "tool-list-container must not be null");
_toolListStatusLabel = CreateToolListStatusLabel();
_toolListView = CreateToolListView();
_toolListContainer.Add(_toolListStatusLabel);
_toolListContainer.Add(_toolListView);
ClearToolList();
Label cliReferenceLink = root.Q<Label>("cli-reference-link");
if (cliReferenceLink != null)
{
cliReferenceLink.RegisterCallback<ClickEvent>(_ => Application.OpenURL(McpUIConstants.CLI_COMMAND_REFERENCE_URL));
}
SetupBindings();
}
private void SetupBindings()
{
_foldout.RegisterValueChangedCallback(evt => OnFoldoutChanged?.Invoke(evt.newValue));
_allowThirdPartyToggle.RegisterValueChangedCallback(evt =>
{
evt.StopPropagation();
OnAllowThirdPartyChanged?.Invoke(evt.newValue);
});
_allowThirdPartyLabel.RegisterCallback<ClickEvent>(evt =>
{
evt.StopPropagation();
bool newValue = !_allowThirdPartyToggle.value;
_allowThirdPartyToggle.SetValueWithoutNotify(newValue);
OnAllowThirdPartyChanged?.Invoke(newValue);
});
_securityLevelRestrictedButton.clicked += () => UpdateSecurityLevel(DynamicCodeSecurityLevel.Restricted);
_securityLevelFullAccessButton.clicked += () => UpdateSecurityLevel(DynamicCodeSecurityLevel.FullAccess);
}
public void Update(ToolSettingsSectionData data)
{
_allowThirdPartyTools = data.AllowThirdPartyTools;
ViewDataBinder.UpdateFoldout(_foldout, data.ShowToolSettings);
ViewDataBinder.UpdateToggle(_allowThirdPartyToggle, data.AllowThirdPartyTools);
UpdateSecurityLevelSelection(data.DynamicCodeSecurityLevel);
UpdateSecurityLevelDescription(data.DynamicCodeSecurityLevel);
if (!data.ShowToolSettings)
{
ClearToolList();
return;
}
if (!data.HasToolListData)
{
UpdateDeferredState();
return;
}
if (!data.IsRegistryAvailable)
{
UpdateUnavailableState();
return;
}
UpdateToolList(data);
}
public void UpdateSingleToggle(string toolName, bool enabled)
{
for (int i = 0; i < _toolListRows.Count; i++)
{
ToolListRowData row = _toolListRows[i];
if (row.IsHeader || row.ToolName != toolName)
{
continue;
}
row.IsEnabled = enabled;
break;
}
if (_togglesByToolName.TryGetValue(toolName, out Toggle toggle))
{
toggle.SetValueWithoutNotify(enabled);
}
RefreshToolListView();
}
private void UpdateDeferredState()
{
if (_toolListRows.Count > 0 || _isUnavailableStateShown)
{
UpdateThirdPartyGroupState(_allowThirdPartyTools);
return;
}
UpdateLoadingState();
}
private void UpdateLoadingState()
{
_toolListRows.Clear();
_togglesByToolName.Clear();
_layoutSignature = string.Empty;
SetToolListStatus("Loading tools...");
ViewDataBinder.SetVisible(_toolListView, false);
SetToolSettingsInfoVisible(false);
_isRegistryAvailable = false;
_isUnavailableStateShown = false;
_isLoadingStateShown = true;
}
private void UpdateUnavailableState()
{
_toolListRows.Clear();
_togglesByToolName.Clear();
_layoutSignature = string.Empty;
SetToolListStatus("Tool registry not yet initialized. Start the server first.");
ViewDataBinder.SetVisible(_toolListView, false);
SetToolSettingsInfoVisible(false);
_isRegistryAvailable = false;
_isUnavailableStateShown = true;
_isLoadingStateShown = false;
}
private void ClearToolList()
{
_toolListRows.Clear();
_togglesByToolName.Clear();
_layoutSignature = string.Empty;
ViewDataBinder.SetVisible(_toolListStatusLabel, false);
ViewDataBinder.SetVisible(_toolListView, false);
SetToolSettingsInfoVisible(false);
RefreshToolListView();
_isRegistryAvailable = false;
_isUnavailableStateShown = false;
_isLoadingStateShown = false;
}
private void SetToolListStatus(string text)
{
_toolListStatusLabel.text = text;
ViewDataBinder.SetVisible(_toolListStatusLabel, true);
}
private void SetToolSettingsInfoVisible(bool visible)
{
if (_toolSettingsInfoContainer == null)
{
return;
}
ViewDataBinder.SetVisible(_toolSettingsInfoContainer, visible);
}
private void UpdateToolList(ToolSettingsSectionData data)
{
string layoutSignature = CreateLayoutSignature(data);
bool shouldRebuild = !_isRegistryAvailable
|| _isUnavailableStateShown
|| _isLoadingStateShown
|| _layoutSignature != layoutSignature;
if (shouldRebuild)
{
Rebuild(data);
_layoutSignature = layoutSignature;
}
else
{
UpdateToggleStates(data.BuiltInTools);
UpdateToggleStates(data.ThirdPartyTools);
}
ViewDataBinder.SetVisible(_toolListStatusLabel, false);
ViewDataBinder.SetVisible(_toolListView, true);
SetToolSettingsInfoVisible(true);
UpdateThirdPartyGroupState(data.AllowThirdPartyTools);
_isRegistryAvailable = true;
_isUnavailableStateShown = false;
_isLoadingStateShown = false;
}
private void UpdateSecurityLevelDescription(DynamicCodeSecurityLevel currentLevel)
{
string description = currentLevel switch
{
DynamicCodeSecurityLevel.Restricted => "Dangerous APIs blocked (recommended)",
DynamicCodeSecurityLevel.FullAccess => "All APIs available (use with caution)",
_ => "Unknown level"
};
_securityLevelDescription.text = description;
_securityLevelDescription.RemoveFromClassList("mcp-security-level-description--warning");
if (currentLevel == DynamicCodeSecurityLevel.FullAccess)
{
_securityLevelDescription.AddToClassList("mcp-security-level-description--warning");
}
}
private void UpdateSecurityLevel(DynamicCodeSecurityLevel newLevel)
{
UpdateSecurityLevelSelection(newLevel);
UpdateSecurityLevelDescription(newLevel);
OnSecurityLevelChanged?.Invoke(newLevel);
}
private void UpdateSecurityLevelSelection(DynamicCodeSecurityLevel currentLevel)
{
bool isRestricted = currentLevel == DynamicCodeSecurityLevel.Restricted;
bool isFullAccess = currentLevel == DynamicCodeSecurityLevel.FullAccess;
ViewDataBinder.ToggleClass(_securityLevelRestrictedButton, "mcp-segmented-control__button--active", isRestricted);
ViewDataBinder.ToggleClass(_securityLevelRestrictedButton, "mcp-segmented-control__button--warning-active", false);
ViewDataBinder.ToggleClass(_securityLevelFullAccessButton, "mcp-segmented-control__button--active", isFullAccess);
ViewDataBinder.ToggleClass(_securityLevelFullAccessButton, "mcp-segmented-control__button--warning-active", isFullAccess);
}
private void Rebuild(ToolSettingsSectionData data)
{
_toolListRows.Clear();
_togglesByToolName.Clear();
if (data.BuiltInTools.Length > 0)
{
_toolListRows.Add(ToolListRowData.CreateHeader("Built-in Tools", false));
AddToolRows(data.BuiltInTools);
}
if (data.ThirdPartyTools.Length > 0)
{
_toolListRows.Add(ToolListRowData.CreateHeader("Third Party Tools", true));
AddToolRows(data.ThirdPartyTools);
}
UpdateToolListHeight();
RefreshToolListView();
}
private void AddToolRows(IReadOnlyList<ToolToggleItem> items)
{
for (int i = 0; i < items.Count; i++)
{
ToolToggleItem item = items[i];
_toolListRows.Add(ToolListRowData.CreateTool(item));
}
}
private void UpdateToggleStates(IReadOnlyList<ToolToggleItem> items)
{
for (int i = 0; i < items.Count; i++)
{
ToolToggleItem item = items[i];
UpdateToggleState(item.ToolName, item.IsEnabled);
}
}
private void UpdateToggleState(string toolName, bool isEnabled)
{
for (int i = 0; i < _toolListRows.Count; i++)
{
ToolListRowData row = _toolListRows[i];
if (row.IsHeader || row.ToolName != toolName)
{
continue;
}
row.IsEnabled = isEnabled;
return;
}
}
private static string CreateLayoutSignature(ToolSettingsSectionData data)
{
StringBuilder builder = new StringBuilder();
AppendGroupSignature(builder, data.BuiltInTools, "B");
AppendGroupSignature(builder, data.ThirdPartyTools, "T");
return builder.ToString();
}
private static void AppendGroupSignature(StringBuilder builder, IReadOnlyList<ToolToggleItem> items, string group)
{
builder.Append(group);
builder.Append(':');
for (int i = 0; i < items.Count; i++)
{
ToolToggleItem item = items[i];
builder.Append(item.ToolName);
builder.Append('|');
}
builder.Append(';');
}
private void UpdateThirdPartyGroupState(bool allowThirdPartyTools)
{
_allowThirdPartyTools = allowThirdPartyTools;
RefreshToolListView();
}
private static Label CreateToolListStatusLabel()
{
Label label = new Label();
label.name = "tool-list-status-label";
label.AddToClassList("mcp-tool-registry-unavailable");
return label;
}
private ListView CreateToolListView()
{
ListView listView = new ListView();
listView.name = "tool-list-view";
listView.AddToClassList("mcp-tool-list-view");
listView.fixedItemHeight = ToolListRowHeight;
listView.virtualizationMethod = CollectionVirtualizationMethod.FixedHeight;
listView.selectionType = SelectionType.None;
listView.itemsSource = _toolListRows;
listView.makeItem = CreateToolListRowElement;
listView.bindItem = BindToolListRowElement;
listView.unbindItem = UnbindToolListRowElement;
return listView;
}
private static VisualElement CreateToolListRowElement()
{
VisualElement row = new VisualElement();
row.AddToClassList("mcp-tool-toggle-row");
row.AddToClassList("mcp-tool-list-row");
Toggle toggle = new Toggle();
toggle.name = "tool-list-row-toggle";
toggle.AddToClassList("mcp-tool-toggle-row__toggle");
toggle.RegisterValueChangedCallback(evt =>
{
evt.StopPropagation();
if (row.userData is not ToolListRowData item || item.IsHeader)
{
return;
}
item.Owner?.OnToolToggled?.Invoke(item.ToolName, evt.newValue);
});
Label label = new Label();
label.name = "tool-list-row-label";
label.AddToClassList("mcp-tool-toggle-row__label");
label.RegisterCallback<ClickEvent>(evt =>
{
evt.StopPropagation();
if (row.userData is not ToolListRowData item || item.IsHeader || !item.CanToggle)
{
return;
}
Toggle rowToggle = row.Q<Toggle>("tool-list-row-toggle");
bool newValue = !rowToggle.value;
rowToggle.SetValueWithoutNotify(newValue);
item.Owner?.OnToolToggled?.Invoke(item.ToolName, newValue);
});
row.Add(toggle);
row.Add(label);
return row;
}
private void BindToolListRowElement(VisualElement row, int index)
{
Debug.Assert(index >= 0 && index < _toolListRows.Count, "tool list index must be valid");
ToolListRowData item = _toolListRows[index];
item.Owner = this;
item.CanToggle = !item.IsThirdParty || _allowThirdPartyTools;
row.userData = item;
Toggle toggle = row.Q<Toggle>("tool-list-row-toggle");
Label label = row.Q<Label>("tool-list-row-label");
Debug.Assert(toggle != null, "tool-list-row-toggle must not be null");
Debug.Assert(label != null, "tool-list-row-label must not be null");
ResetRowClasses(row, label);
if (item.IsHeader)
{
BindHeaderRow(row, toggle, label, item);
return;
}
BindToolRow(row, toggle, label, item);
}
private void BindHeaderRow(VisualElement row, Toggle toggle, Label label, ToolListRowData item)
{
ViewDataBinder.SetVisible(toggle, false);
label.text = item.Label;
label.tooltip = string.Empty;
row.SetEnabled(true);
row.AddToClassList("mcp-tool-list-row--header");
label.AddToClassList("mcp-tool-group-header");
ViewDataBinder.ToggleClass(row, "mcp-tool-list-row--disabled", item.IsThirdParty && !_allowThirdPartyTools);
}
private void BindToolRow(VisualElement row, Toggle toggle, Label label, ToolListRowData item)
{
ViewDataBinder.SetVisible(toggle, true);
toggle.SetValueWithoutNotify(item.IsEnabled);
label.text = item.ToolName;
label.tooltip = item.Description;
row.SetEnabled(item.CanToggle);
ViewDataBinder.ToggleClass(row, "mcp-tool-list-row--disabled", !item.CanToggle);
_togglesByToolName[item.ToolName] = toggle;
}
private static void ResetRowClasses(VisualElement row, Label label)
{
row.RemoveFromClassList("mcp-tool-list-row--header");
row.RemoveFromClassList("mcp-tool-list-row--disabled");
label.RemoveFromClassList("mcp-tool-group-header");
}
private void UnbindToolListRowElement(VisualElement row, int index)
{
if (row.userData is ToolListRowData item && !item.IsHeader)
{
_togglesByToolName.Remove(item.ToolName);
}
row.userData = null;
}
private void RefreshToolListView()
{
_togglesByToolName.Clear();
_toolListView.RefreshItems();
}
private void UpdateToolListHeight()
{
int visibleRows = Math.Min(_toolListRows.Count, InlineToolRowLimit);
if (visibleRows <= 0)
{
visibleRows = 1;
}
_toolListView.style.height = (visibleRows * ToolListRowHeight) + 2;
}
private sealed class ToolListRowData
{
public readonly bool IsHeader;
public readonly string ToolName;
public readonly string Label;
public readonly string Description;
public readonly bool IsThirdParty;
public bool IsEnabled;
public bool CanToggle = true;
public ToolSettingsSection Owner;
private ToolListRowData(
bool isHeader,
string toolName,
string label,
string description,
bool isEnabled,
bool isThirdParty)
{
IsHeader = isHeader;
ToolName = toolName;
Label = label;
Description = description;
IsEnabled = isEnabled;
IsThirdParty = isThirdParty;
}
public static ToolListRowData CreateHeader(string label, bool isThirdParty)
{
return new ToolListRowData(
true,
string.Empty,
label,
string.Empty,
true,
isThirdParty);
}
public static ToolListRowData CreateTool(ToolToggleItem item)
{
return new ToolListRowData(
false,
item.ToolName,
item.ToolName,
item.Description,
item.IsEnabled,
item.IsThirdParty);
}
}
}
}