diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fbbe62..24db2ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to this package will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [1.0.1] - 2026-02-06 + +**New**: +- Added new unit tests for `ConfigTreeBuilder`, `ConfigValidationService`, and `ConfigsEditorUtil` + +**Changes**: +- Decomposed monolithic `ConfigBrowserWindow` and `MigrationPanelElement` into MVC architecture +- Simplified `README.md` by removing the inline package structure and contents + +--- + ## [1.0.0] - 2026-01-21 **New**: @@ -44,53 +55,53 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [0.7.0] - 2025-11-03 **New**: -- Added *Rebind* functionality to all Observable classes (*ObservableField*, *ObservableList*, *ObservableDictionary*) allowing rebinding to new data sources without losing existing observers -- Added *Rebind* methods to all Observable Resolver classes (*ObservableResolverField*, *ObservableResolverList*, *ObservableResolverDictionary*) to rebind to new origin collections and resolver functions -- Added new *IObservableResolverField* interface with *Rebind* method for resolver field implementations +- Added `Rebind` functionality to all Observable classes (`ObservableField`, `ObservableList`, `ObservableDictionary`) allowing rebinding to new data sources without losing existing observers +- Added `Rebind` methods to all Observable Resolver classes (`ObservableResolverField`, `ObservableResolverList`, `ObservableResolverDictionary`) to rebind to new origin collections and resolver functions +- Added new `IObservableResolverField` interface with `Rebind` method for resolver field implementations ## [0.6.7] - 2025-04-07 **New**: -- Added the *UnityObjectExtensions* to help add extra logic to Unity's *GameObject* type objects +- Added the `UnityObjectExtensions` to help add extra logic to Unity's `GameObject` type objects ## [0.6.6] - 2024-11-30 **Fixes**: -- *ObservableDictionary.Remove(T)* no longer sends an update if id doesn't find the element to remove it +- `ObservableDictionary.Remove(T)` no longer sends an update if id doesn't find the element to remove it ## [0.6.5] - 2024-11-20 **Fixes**: -- Fixed the issues of *ObservableDictionary* when subscribing/unsubscribing to actions while removing/adding elements -- Fixed the issues of *ObservableList* when subscribing/unsubscribing to actions while removing/adding elements +- Fixed the issues of `ObservableDictionary` when subscribing/unsubscribing to actions while removing/adding elements +- Fixed the issues of `ObservableList` when subscribing/unsubscribing to actions while removing/adding elements ## [0.6.4] - 2024-11-13 **Fixes**: -- Fixed the unit tests for *ObservableDictionary* that was preventing some builds to run +- Fixed the unit tests for `ObservableDictionary` that was preventing some builds to run ## [0.6.3] - 2024-11-02 **Fixes**: -- Fixed the compilation issues of *ObservableDictionary* +- Fixed the compilation issues of `ObservableDictionary` ## [0.6.2] - 2024-11-02 **New**: -- Added the *ObservableUpdateFlag* to help performance when updating subscribers to the *ObservableDictionary*. By default is set *ObservableUpdateFlag.KeyUpdateOnly* +- Added the `ObservableUpdateFlag` to help performance when updating subscribers to the `ObservableDictionary`. By default is set `ObservableUpdateFlag.KeyUpdateOnly` **Fixes**: -- Fixed an issue that would no setup Remove update action to Subscribers when calling *Clear* on the *ObservableDictionary* +- Fixed an issue that would no setup Remove update action to Subscribers when calling `Clear` on the `ObservableDictionary` ## [0.6.1] - 2024-11-01 **Fixes**: -- Fixed an issue that would crash the execution when calling *Remove()* & *RemoveOrigin* from *ObservableResolverDictionary* +- Fixed an issue that would crash the execution when calling `Remove()` & `RemoveOrigin` from `ObservableResolverDictionary` ## [0.6.0] - 2023-08-05 **Changes**: -- Improved the *ObservableResolverList* and *ObservableResolverDictionary* data types to properly resolve lists and dictionaries with different data types from the original collection. +- Improved the `ObservableResolverList` and `ObservableResolverDictionary` data types to properly resolve lists and dictionaries with different data types from the original collection. ## [0.5.1] - 2023-09-04 @@ -120,13 +131,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [0.2.0] - 2020-09-28 **New**: -- Added new *ObservableResolverList*, *ObservableResolverDictionary* & *ObservableResolverField* to allow to create observable types without referencing the collection directly +- Added new `ObservableResolverList`, `ObservableResolverDictionary` & `ObservableResolverField` to allow to create observable types without referencing the collection directly - Added Unit tests to all data types in the project **Changes**: -- Removed *ObservableIdList* because it's behaviour was too confusing and the same result can be obtained with *ObservableList* or *ObservableDictionary* -- Removed all Pair Data and moved them to new *Pair* serialized type that can now be serializable on Unity 2020.1 -- Moved all Vector2, Vector3 & Vector4 extensions to the ValueData file +- Removed `ObservableIdList` because it's behaviour was too confusing and the same result can be obtained with `ObservableList` or `ObservableDictionary` +- Removed all Pair Data and moved them to new `Pair` serialized type that can now be serializable on *Unity 2020.1* +- Moved all `Vector2`, `Vector3` & `Vector4` extensions to the `ValueData` file ## [0.1.1] - 2020-08-31 diff --git a/Editor/AssemblyInfo.cs b/Editor/AssemblyInfo.cs new file mode 100644 index 0000000..03c86fc --- /dev/null +++ b/Editor/AssemblyInfo.cs @@ -0,0 +1,8 @@ +// Copyright (c) GameLovers. All rights reserved. + +using System.Runtime.CompilerServices; + +/// +/// Exposes internal editor types to the editor test assembly for unit testing. +/// +[assembly: InternalsVisibleTo("GameLovers.GameData.Editor.Tests")] diff --git a/Editor/AssemblyInfo.cs.meta b/Editor/AssemblyInfo.cs.meta new file mode 100644 index 0000000..8deef78 --- /dev/null +++ b/Editor/AssemblyInfo.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 153ac2b5b27004d94a039f8823fe1a94 \ No newline at end of file diff --git a/Editor/Elements/MigrationPanel.meta b/Editor/Elements/MigrationPanel.meta new file mode 100644 index 0000000..bec98b4 --- /dev/null +++ b/Editor/Elements/MigrationPanel.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e6e7a6d7062fd49da9a040deccb1f45a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Elements/MigrationPanel/MigrationPanelController.cs b/Editor/Elements/MigrationPanel/MigrationPanelController.cs new file mode 100644 index 0000000..8537195 --- /dev/null +++ b/Editor/Elements/MigrationPanel/MigrationPanelController.cs @@ -0,0 +1,280 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using GameLovers.GameData; +using GameLoversEditor.GameData; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace GameLovers.GameData.Editor +{ + /// + /// Controller that drives the migration panel UI. + /// Discovers available migrations from , populates the + /// with rows, and handles preview / apply actions. + /// + internal sealed class MigrationPanelController + { + private readonly MigrationPanelView _view; + private readonly List _rows = new List(); + private IConfigsProvider _provider; + + public MigrationPanelController(MigrationPanelView view) + { + _view = view; + _view.PreviewRequested += OnPreviewRequested; + _view.ApplyRequested += OnApplyRequested; + } + + /// + /// Assigns the to inspect for migrations and rebuilds the panel. + /// + public void SetProvider(IConfigsProvider provider) + { + _provider = provider; + Rebuild(); + } + + /// + /// Rebuilds the migration row list and refreshes the view. + /// + public void Rebuild() + { + _rows.Clear(); + _view.SetInputJson(string.Empty); + _view.SetOutputJson(string.Empty); + _view.SetLog(string.Empty); + + if (_provider == null) + { + _view.SetHeader("Migrations (no provider)"); + _view.SetEmptyStateVisible(false); + _view.SetRows(_rows); + var hasChoices = _rows.Count > 0; + _view.SetButtonsEnabled(hasChoices, hasChoices && _provider != null); + return; + } + + var providerTypes = _provider.GetAllConfigs().Keys.ToHashSet(); + var migratableTypes = MigrationRunner.GetConfigTypesWithMigrations() + .Where(providerTypes.Contains) + .OrderBy(t => t.Name) + .ToList(); + + // Show empty state when no migrations are available. + if (migratableTypes.Count == 0) + { + _view.SetHeader("Migrations"); + _view.SetEmptyStateVisible(true); + _view.SetRows(_rows); + _view.SetButtonsEnabled(false, false); + return; + } + + _view.SetEmptyStateVisible(false); + + var currentVersion = _provider.Version; + var latestVersion = migratableTypes.Max(t => MigrationRunner.GetLatestVersion(t)); + _view.SetHeader($"Current Config Version: {currentVersion} Latest Available: {latestVersion}"); + + foreach (var type in migratableTypes) + { + var migrations = MigrationRunner.GetAvailableMigrations(type); + for (int i = 0; i < migrations.Count; i++) + { + var m = migrations[i]; + var state = GetState(currentVersion, m.FromVersion, m.ToVersion); + _rows.Add(new MigrationRow(type, m.FromVersion, m.ToVersion, m.MigrationType, state)); + } + } + + _view.SetRows(_rows); + var hasRows = _rows.Count > 0; + _view.SetButtonsEnabled(hasRows, hasRows && _provider != null); + } + + private void OnPreviewRequested() + { + var selectedIndex = _view.SelectedIndex; + if (selectedIndex < 0 || selectedIndex >= _rows.Count) + { + return; + } + + RunPreview(_rows[selectedIndex]); + } + + private void OnApplyRequested() + { + var selectedIndex = _view.SelectedIndex; + if (selectedIndex < 0 || selectedIndex >= _rows.Count || _provider == null) + { + return; + } + + var row = _rows[selectedIndex]; + ApplyMigration(row); + } + + private void ApplyMigration(MigrationRow row) + { + // UpdateTo is only available on ConfigsProvider, not the interface + if (!(_provider is ConfigsProvider concreteProvider)) + { + _view.SetLog("Apply Failed: Provider does not support UpdateTo (must be ConfigsProvider)."); + return; + } + + var currentVersion = _provider.Version; + + // Get all configs of this type from the provider + var allConfigs = _provider.GetAllConfigs(); + if (!allConfigs.TryGetValue(row.ConfigType, out var container)) + { + _view.SetLog($"Apply Failed: No configs of type {row.ConfigType.Name} found in provider."); + return; + } + + // Read configs into a list of (id, value) pairs + if (!ConfigsEditorUtil.TryReadConfigs(container, out var entries) || entries.Count == 0) + { + _view.SetLog($"Apply Failed: Could not read configs of type {row.ConfigType.Name}."); + return; + } + + try + { + // Migrate each config and collect results + var migratedEntries = new List<(int Id, object Value)>(); + int totalApplied = 0; + + foreach (var entry in entries) + { + var inputJson = JObject.FromObject(entry.Value); + var outputJson = (JObject)inputJson.DeepClone(); + + var applied = MigrationRunner.Migrate(row.ConfigType, outputJson, currentVersion, row.ToVersion); + totalApplied += applied; + + // Deserialize back to the config type + var migratedValue = outputJson.ToObject(row.ConfigType); + migratedEntries.Add((entry.Id, migratedValue)); + } + + // Create a new dictionary with migrated values using reflection + var dictionaryType = typeof(Dictionary<,>).MakeGenericType(typeof(int), row.ConfigType); + var newDictionary = (IDictionary)Activator.CreateInstance(dictionaryType); + + foreach (var (id, value) in migratedEntries) + { + newDictionary.Add(id, value); + } + + // Update the provider + var updateDict = new Dictionary { { row.ConfigType, newDictionary } }; + concreteProvider.UpdateTo(row.ToVersion, updateDict); + + _view.SetLog($"Apply Success: Migrated {entries.Count} config(s), {totalApplied} migration step(s) applied. Provider now at v{row.ToVersion}."); + + // Rebuild to reflect new state + Rebuild(); + } + catch (Exception ex) + { + _view.SetLog($"Apply Failed: {ex.Message}"); + } + } + + private void RunPreview(MigrationRow row) + { + if (_provider == null) + { + return; + } + + var currentVersion = _provider.Version; + JObject inputJson; + string instanceLabel; + + // Priority 1: Custom JSON input from the text field + var customJson = _view.CustomJson; + if (!string.IsNullOrEmpty(customJson)) + { + try + { + inputJson = JObject.Parse(customJson); + instanceLabel = $"{row.ConfigType.Name} (custom input)"; + } + catch (Exception ex) + { + _view.SetInputJson($"// Invalid JSON: {ex.Message}"); + _view.SetOutputJson(string.Empty); + _view.SetLog(string.Empty); + return; + } + } + // Priority 2: Provider data (current schema) + else if (TryGetFirstInstance(row.ConfigType, out var id, out var instance)) + { + inputJson = JObject.FromObject(instance); + var idStr = id == 0 ? "singleton" : id.ToString(); + instanceLabel = $"{row.ConfigType.Name} ({idStr})"; + } + else + { + _view.SetInputJson("// No instance found for this config type in the provider."); + _view.SetOutputJson(string.Empty); + _view.SetLog(string.Empty); + return; + } + + var outputJson = (JObject)inputJson.DeepClone(); + + int applied; + try + { + applied = MigrationRunner.Migrate(row.ConfigType, outputJson, currentVersion, row.ToVersion); + } + catch (Exception ex) + { + _view.SetInputJson(inputJson.ToString(Formatting.Indented)); + _view.SetOutputJson($"// Migration failed: {ex.Message}"); + _view.SetLog($"Migration Log: {row.MigrationType.Name} - FAILED"); + return; + } + + _view.SetInputJson(inputJson.ToString(Formatting.Indented)); + _view.SetOutputJson(outputJson.ToString(Formatting.Indented)); + _view.SetLog($"Migration Log: {row.MigrationType.Name} - SUCCESS (Applied: {applied}) Preview Instance: {instanceLabel}"); + } + + private bool TryGetFirstInstance(Type configType, out int id, out object instance) + { + id = 0; + instance = null; + + var all = _provider.GetAllConfigs(); + if (!all.TryGetValue(configType, out var container)) + { + return false; + } + + if (!ConfigsEditorUtil.TryReadConfigs(container, out var entries) || entries.Count == 0) + { + return false; + } + + id = entries[0].Id; + instance = entries[0].Value; + return instance != null; + } + + private static MigrationState GetState(ulong currentVersion, ulong from, ulong to) + { + if (currentVersion >= to) return MigrationState.Applied; + if (currentVersion == from) return MigrationState.Current; + return MigrationState.Pending; + } + } +} diff --git a/Editor/Elements/MigrationPanel/MigrationPanelController.cs.meta b/Editor/Elements/MigrationPanel/MigrationPanelController.cs.meta new file mode 100644 index 0000000..53d05f5 --- /dev/null +++ b/Editor/Elements/MigrationPanel/MigrationPanelController.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d700add4528bc4b4e95526d7b522715b \ No newline at end of file diff --git a/Editor/Elements/MigrationPanel/MigrationPanelModels.cs b/Editor/Elements/MigrationPanel/MigrationPanelModels.cs new file mode 100644 index 0000000..05c2727 --- /dev/null +++ b/Editor/Elements/MigrationPanel/MigrationPanelModels.cs @@ -0,0 +1,37 @@ +using System; + +namespace GameLovers.GameData.Editor +{ + /// + /// Immutable data row representing a single config migration step displayed in the migration panel. + /// Each row maps a from to + /// using the migration logic defined by . + /// + internal readonly struct MigrationRow + { + public readonly Type ConfigType; + public readonly ulong FromVersion; + public readonly ulong ToVersion; + public readonly Type MigrationType; + public readonly MigrationState State; + + public MigrationRow(Type configType, ulong fromVersion, ulong toVersion, Type migrationType, MigrationState state) + { + ConfigType = configType; + FromVersion = fromVersion; + ToVersion = toVersion; + MigrationType = migrationType; + State = state; + } + } + + /// + /// Describes the lifecycle state of a migration relative to the current provider version. + /// + internal enum MigrationState + { + Applied, // The migration has already been applied (current version is greater than or equal to ToVersion) + Current, // The migration is the next one to apply (current version equals FromVersion) + Pending // The migration is pending (current version is less than FromVersion) + } +} diff --git a/Editor/Elements/MigrationPanel/MigrationPanelModels.cs.meta b/Editor/Elements/MigrationPanel/MigrationPanelModels.cs.meta new file mode 100644 index 0000000..917e3ea --- /dev/null +++ b/Editor/Elements/MigrationPanel/MigrationPanelModels.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ec05e83a52439450496ceafc7ac26d30 \ No newline at end of file diff --git a/Editor/Elements/MigrationPanel/MigrationPanelView.cs b/Editor/Elements/MigrationPanel/MigrationPanelView.cs new file mode 100644 index 0000000..df3f906 --- /dev/null +++ b/Editor/Elements/MigrationPanel/MigrationPanelView.cs @@ -0,0 +1,290 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEngine; +using UnityEngine.UIElements; + +namespace GameLovers.GameData.Editor +{ + /// + /// UI Toolkit view that renders the migration panel layout: a list of migration rows, + /// a preview section with before/after JSON viewers, and controls for previewing and applying migrations. + /// This view is purely presentational; logic lives in . + /// + internal sealed class MigrationPanelView : VisualElement + { + private Label _header; + private HelpBox _emptyState; + private VisualElement _contentContainer; + private ListView _listView; + private List _rows = new List(); + + private TextField _customJsonInput; + private DropdownField _migrationDropdown; + private Button _previewButton; + private Button _applyButton; + private JsonViewerElement _inputJson; + private JsonViewerElement _outputJson; + private Label _logLabel; + + public event Action PreviewRequested; + public event Action ApplyRequested; + + public MigrationPanelView() + { + style.flexGrow = 1; + style.paddingLeft = 8; + style.paddingRight = 8; + style.paddingTop = 6; + style.paddingBottom = 6; + + _header = new Label("Migrations"); + _header.style.unityFontStyleAndWeight = FontStyle.Bold; + _header.style.marginBottom = 6; + + _emptyState = new HelpBox("No migrations available in this project.", HelpBoxMessageType.Info); + _emptyState.style.display = DisplayStyle.None; + + // Vertical split: migrations list (top) + preview section (bottom) + var verticalSplit = new TwoPaneSplitView(1, 220, TwoPaneSplitViewOrientation.Vertical); + verticalSplit.viewDataKey = "ConfigBrowser_MigrationVerticalSplit"; + verticalSplit.style.flexGrow = 1; + + verticalSplit.Add(BuildMigrationsList()); + verticalSplit.Add(BuildPreview()); + + _contentContainer = new VisualElement { style = { flexGrow = 1 } }; + _contentContainer.Add(verticalSplit); + + Add(_header); + Add(_emptyState); + Add(_contentContainer); + } + + /// + /// Gets the trimmed custom JSON input provided by the user, or null when empty. + /// + public string CustomJson + { + get + { + var value = _customJsonInput?.value; + return value == null ? null : value.Trim(); + } + } + + /// + /// Gets the currently selected migration dropdown index, or -1 if nothing is selected. + /// + public int SelectedIndex => _migrationDropdown?.index ?? -1; + + /// + /// Updates the header label text. + /// + public void SetHeader(string text) + { + _header.text = text; + } + + /// + /// Toggles between the empty-state help box and the content container. + /// + public void SetEmptyStateVisible(bool visible) + { + _emptyState.style.display = visible ? DisplayStyle.Flex : DisplayStyle.None; + _contentContainer.style.display = visible ? DisplayStyle.None : DisplayStyle.Flex; + } + + /// + /// Replaces the displayed migration rows and refreshes the dropdown choices. + /// + public void SetRows(List rows) + { + _rows = rows ?? new List(); + _listView.itemsSource = _rows; + _listView.RefreshItems(); + + var choices = _rows + .Select(r => $"{r.ConfigType.Name}: v{r.FromVersion} → v{r.ToVersion}") + .ToList(); + + _migrationDropdown.choices = choices; + if (choices.Count > 0) + { + _migrationDropdown.index = 0; + } + } + + /// + /// Enables or disables the preview and apply buttons. + /// + public void SetButtonsEnabled(bool hasChoices, bool canApply) + { + _previewButton.SetEnabled(hasChoices); + _applyButton.SetEnabled(canApply); + } + + /// + /// Displays the given JSON string in the input (before) viewer. + /// + public void SetInputJson(string json) + { + _inputJson.SetJson(json); + } + + /// + /// Displays the given JSON string in the output (after) viewer. + /// + public void SetOutputJson(string json) + { + _outputJson.SetJson(json); + } + + /// + /// Sets the migration log message displayed below the preview. + /// + public void SetLog(string message) + { + _logLabel.text = message ?? string.Empty; + } + + private VisualElement BuildMigrationsList() + { + var container = new VisualElement { style = { flexGrow = 1, minHeight = 50 } }; + + _listView = new ListView + { + selectionType = SelectionType.Single, + showBorder = true, + showAlternatingRowBackgrounds = AlternatingRowBackground.ContentOnly, + style = { flexGrow = 1 } + }; + + _listView.makeItem = MakeRow; + _listView.bindItem = (e, i) => BindRow(e, i); + + container.Add(_listView); + return container; + } + + private VisualElement BuildPreview() + { + var container = new VisualElement + { + style = + { + flexGrow = 1, + minHeight = 60, + borderTopWidth = 1, + borderTopColor = new StyleColor(new Color(0f, 0f, 0f, 0.2f)), + paddingTop = 4 + } + }; + + var title = new Label("Migration Preview"); + title.style.unityFontStyleAndWeight = FontStyle.Bold; + title.style.marginBottom = 4; + + // Custom JSON input section + var customInputSection = new VisualElement { style = { marginBottom = 6 } }; + var customInputLabel = new Label("Custom Input JSON (paste legacy-schema JSON here):"); + customInputLabel.style.marginBottom = 2; + + _customJsonInput = new TextField + { + multiline = true, + style = + { + minHeight = 40, + maxHeight = 100 + } + }; + _customJsonInput.AddToClassList("unity-text-field__input"); + + // Migration selection and preview button row + var previewControlsRow = new VisualElement + { + style = + { + flexDirection = FlexDirection.Row, + alignItems = Align.Center, + marginTop = 4 + } + }; + + var dropdownLabel = new Label("Target Version:") { style = { marginRight = 4 } }; + _migrationDropdown = new DropdownField { style = { minWidth = 120, marginRight = 8 } }; + _previewButton = new Button(() => PreviewRequested?.Invoke()) { text = "Preview" }; + _applyButton = new Button(() => ApplyRequested?.Invoke()) { text = "Apply Migration" }; + _applyButton.style.marginLeft = 8; + + previewControlsRow.Add(dropdownLabel); + previewControlsRow.Add(_migrationDropdown); + previewControlsRow.Add(_previewButton); + previewControlsRow.Add(_applyButton); + + customInputSection.Add(customInputLabel); + customInputSection.Add(_customJsonInput); + customInputSection.Add(previewControlsRow); + + var split = new TwoPaneSplitView(0, 360, TwoPaneSplitViewOrientation.Horizontal); + split.style.flexGrow = 1; + split.style.minHeight = 60; + + var left = new VisualElement { style = { flexGrow = 1 } }; + left.Add(new Label("Input")); + _inputJson = new JsonViewerElement(); + left.Add(_inputJson); + + var right = new VisualElement { style = { flexGrow = 1 } }; + right.Add(new Label("Output")); + _outputJson = new JsonViewerElement(); + right.Add(_outputJson); + + split.Add(left); + split.Add(right); + + _logLabel = new Label(); + _logLabel.style.marginTop = 4; + _logLabel.style.whiteSpace = WhiteSpace.Normal; + + container.Add(title); + container.Add(customInputSection); + container.Add(split); + container.Add(_logLabel); + return container; + } + + private static VisualElement MakeRow() + { + var row = new VisualElement + { + style = + { + flexDirection = FlexDirection.Row, + alignItems = Align.Center, + paddingLeft = 6, + paddingRight = 6, + paddingTop = 2, + paddingBottom = 2 + } + }; + + row.Add(new Label { name = "Type", style = { minWidth = 160 } }); + row.Add(new Label { name = "Migration", style = { minWidth = 100 } }); + row.Add(new Label { name = "State", style = { minWidth = 90 } }); + + return row; + } + + private void BindRow(VisualElement row, int index) + { + if (index < 0 || index >= _rows.Count) return; + var data = _rows[index]; + + row.Q