Class Sealed
public sealed class MokaJsonViewer : ComponentBase, IAsyncDisposable, IMokaJsonViewer

Namespace: Moka.Blazor.Json.Components

Top-level JSON viewer and editor component. The primary entry point for consumers.

Inheritance

Inherits from: ComponentBase

Implements: IAsyncDisposable, IMokaJsonViewer

Properties

NameDescription
AdditionalAttributes Additional HTML attributes to apply to the root element.
CollapseMode Initial collapse behavior when a document is loaded. Default is MokaJsonCollapseMode.Depth.
ContextMenuActions Custom context menu actions to add.
DebugStats Debug stats from the lazy source, set only when MOKA_DEBUG_LAZY env var is set. Used by Moka.Blazor.Json.Diagnostics overlay.
Height Height of the viewer. Default is "400px". Use "100%" for fill.
IsEditing
Json The JSON string to display. Mutually exclusive with MokaJsonViewer.JsonStream.
JsonChanged Two-way binding callback for MokaJsonViewer.Json. Enables @bind-Json.
JsonStream A stream containing JSON data for incremental parsing.
MaxDepthExpanded Initial depth to expand. Default is 2.
OnError Callback when an error occurs.
OnJsonChanged Callback when the JSON content changes in edit mode.
OnNodeSelected Callback when a node is selected.
ReadOnly Whether the viewer is read-only. Default is true.
SelectedPath
ShowBottomBar Whether the bottom status bar is displayed. Default is true.
ShowBreadcrumb Whether the breadcrumb path is displayed. Default is true.
ShowChildCount Whether to show child count (e.g. "13 items") on collapsed containers. Default is true.
ShowLineNumbers Whether to show line numbers in the gutter. Default is true.
ShowSettingsButton Whether the settings gear button is shown in the toolbar. When null, the global default from DI (MokaJsonViewerOptions.ShowSettingsButton) is used.
ShowToolbar Whether the toolbar is displayed. Default is true.
Theme Theme mode for the viewer.
ToggleSize Size of expand/collapse toggle indicators. Default is MokaJsonToggleSize.Small.
ToggleStyle Style of expand/collapse toggle indicators. Default is MokaJsonToggleStyle.Triangle.
ToolbarExtra Extra content to render in the toolbar.
ToolbarMode How toolbar buttons are displayed. Overrides MokaJsonViewerOptions.DefaultToolbarMode. When null, the global default from DI is used.
WordWrap Whether long values wrap to the next line. Default is true.

AdditionalAttributes

Dictionary<string, object>? MokaJsonViewer.AdditionalAttributes { get; set; }

Additional HTML attributes to apply to the root element.

CollapseMode

MokaJsonCollapseMode MokaJsonViewer.CollapseMode { get; set; }

Initial collapse behavior when a document is loaded. Default is MokaJsonCollapseMode.Depth.

ContextMenuActions

IReadOnlyList<MokaJsonContextAction>? MokaJsonViewer.ContextMenuActions { get; set; }

Custom context menu actions to add.

DebugStats

LazyDebugStats? MokaJsonViewer.DebugStats { get; set; }

Debug stats from the lazy source, set only when MOKA_DEBUG_LAZY env var is set. Used by Moka.Blazor.Json.Diagnostics overlay.

Height

string MokaJsonViewer.Height { get; set; }

Height of the viewer. Default is "400px". Use "100%" for fill.

Json

string? MokaJsonViewer.Json { get; set; }

The JSON string to display. Mutually exclusive with MokaJsonViewer.JsonStream.

JsonChanged

EventCallback<string?> MokaJsonViewer.JsonChanged { get; set; }

Two-way binding callback for MokaJsonViewer.Json. Enables @bind-Json.

JsonStream

Stream? MokaJsonViewer.JsonStream { get; set; }

A stream containing JSON data for incremental parsing.

MaxDepthExpanded

int MokaJsonViewer.MaxDepthExpanded { get; set; }

Initial depth to expand. Default is 2.

OnError

EventCallback<JsonErrorEventArgs> MokaJsonViewer.OnError { get; set; }

Callback when an error occurs.

OnJsonChanged

EventCallback<JsonChangeEventArgs> MokaJsonViewer.OnJsonChanged { get; set; }

Callback when the JSON content changes in edit mode.

OnNodeSelected

EventCallback<JsonNodeSelectedEventArgs> MokaJsonViewer.OnNodeSelected { get; set; }

Callback when a node is selected.

ReadOnly

bool MokaJsonViewer.ReadOnly { get; set; }

Whether the viewer is read-only. Default is true.

ShowBottomBar

bool MokaJsonViewer.ShowBottomBar { get; set; }

Whether the bottom status bar is displayed. Default is true.

ShowBreadcrumb

bool MokaJsonViewer.ShowBreadcrumb { get; set; }

Whether the breadcrumb path is displayed. Default is true.

ShowChildCount

bool MokaJsonViewer.ShowChildCount { get; set; }

Whether to show child count (e.g. "13 items") on collapsed containers. Default is true.

ShowLineNumbers

bool MokaJsonViewer.ShowLineNumbers { get; set; }

Whether to show line numbers in the gutter. Default is true.

ShowSettingsButton

bool? MokaJsonViewer.ShowSettingsButton { get; set; }

Whether the settings gear button is shown in the toolbar. When null, the global default from DI (MokaJsonViewerOptions.ShowSettingsButton) is used.

ShowToolbar

bool MokaJsonViewer.ShowToolbar { get; set; }

Whether the toolbar is displayed. Default is true.

Theme

MokaJsonTheme MokaJsonViewer.Theme { get; set; }

Theme mode for the viewer.

ToggleSize

MokaJsonToggleSize MokaJsonViewer.ToggleSize { get; set; }

Size of expand/collapse toggle indicators. Default is MokaJsonToggleSize.Small.

ToggleStyle

MokaJsonToggleStyle MokaJsonViewer.ToggleStyle { get; set; }

Style of expand/collapse toggle indicators. Default is MokaJsonToggleStyle.Triangle.

ToolbarExtra

RenderFragment? MokaJsonViewer.ToolbarExtra { get; set; }

Extra content to render in the toolbar.

ToolbarMode

MokaJsonToolbarMode? MokaJsonViewer.ToolbarMode { get; set; }

How toolbar buttons are displayed. Overrides MokaJsonViewerOptions.DefaultToolbarMode. When null, the global default from DI is used.

WordWrap

bool MokaJsonViewer.WordWrap { get; set; }

Whether long values wrap to the next line. Default is true.

Methods

Type Relationships
classDiagram
                    style MokaJsonViewer fill:#f9f,stroke:#333,stroke-width:2px
                    MokaJsonViewer --|> ComponentBase : inherits
                    MokaJsonViewer ..|> IAsyncDisposable : implements
                    MokaJsonViewer ..|> IMokaJsonViewer : implements
                
View Source
/// <summary>
///     Top-level JSON viewer and editor component. The primary entry point for consumers.
/// </summary>
public sealed partial class MokaJsonViewer : ComponentBase, IMokaJsonViewer, IAsyncDisposable
{
#region IAsyncDisposable
    /// <inheritdoc/>
    public async ValueTask DisposeAsync()
    {
        if (_disposed)
        {
            return;
        }

        _disposed = true;
        _searchDebounceTimer?.Dispose();
        _toastTimer?.Dispose();
        if (_treeCts is not null)
        {
            await _treeCts.CancelAsync();
            _treeCts.Dispose();
        }

        if (_statsCts is not null)
        {
            await _statsCts.CancelAsync();
            _statsCts.Dispose();
        }

        if (_documentSource is not null)
        {
            await _documentSource.DisposeAsync();
        }
        else if (_documentManager is not null)
        {
            await _documentManager.DisposeAsync();
        }

        if (_contextMenu is not null)
        {
            await _contextMenu.DisposeAsync();
        }

        if (OptionsAccessor.Value.AggressiveCleanup)
        {
            GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
            GC.Collect();
            GC.WaitForPendingFinalizers();
            GC.Collect();
        }
    }

#endregion
#region Injected Services
    [Inject]
    private ILoggerFactory LoggerFactory { get; set; } = null!;

    [Inject]
    private IOptions<MokaJsonViewerOptions> OptionsAccessor { get; set; } = null!;

    [Inject]
    private MokaJsonInterop Interop { get; set; } = null!;

#endregion
#region Parameters
    /// <summary>
    ///     The JSON string to display. Mutually exclusive with <see cref = "JsonStream"/>.
    /// </summary>
    [Parameter]
    public string? Json { get; set; }

    /// <summary>
    ///     A stream containing JSON data for incremental parsing.
    /// </summary>
    [Parameter]
    public Stream? JsonStream { get; set; }

    /// <summary>
    ///     Theme mode for the viewer.
    /// </summary>
    [Parameter]
    public MokaJsonTheme Theme { get; set; } = MokaJsonTheme.Auto;

    /// <summary>
    ///     Whether the toolbar is displayed. Default is <c>true</c>.
    /// </summary>
    [Parameter]
    public bool ShowToolbar { get; set; } = true;

    /// <summary>
    ///     Whether the bottom status bar is displayed. Default is <c>true</c>.
    /// </summary>
    [Parameter]
    public bool ShowBottomBar { get; set; } = true;

    /// <summary>
    ///     Whether the breadcrumb path is displayed. Default is <c>true</c>.
    /// </summary>
    [Parameter]
    public bool ShowBreadcrumb { get; set; } = true;

    /// <summary>
    ///     Whether to show line numbers in the gutter. Default is <c>true</c>.
    /// </summary>
    [Parameter]
    public bool ShowLineNumbers { get; set; }

    /// <summary>
    ///     Whether the viewer is read-only. Default is <c>true</c>.
    /// </summary>
    [Parameter]
    public bool ReadOnly { get; set; } = true;

    /// <summary>
    ///     Initial depth to expand. Default is <c>2</c>.
    /// </summary>
    [Parameter]
    public int MaxDepthExpanded { get; set; } = 2;

    /// <summary>
    ///     Height of the viewer. Default is "400px". Use "100%" for fill.
    /// </summary>
    [Parameter]
    public string Height { get; set; } = "400px";

    /// <summary>
    ///     Callback when a node is selected.
    /// </summary>
    [Parameter]
    public EventCallback<JsonNodeSelectedEventArgs> OnNodeSelected { get; set; }

    /// <summary>
    ///     Callback when the JSON content changes in edit mode.
    /// </summary>
    [Parameter]
    public EventCallback<JsonChangeEventArgs> OnJsonChanged { get; set; }

    /// <summary>
    ///     Two-way binding callback for <see cref = "Json"/>. Enables <c>@bind-Json</c>.
    /// </summary>
    [Parameter]
    public EventCallback<string?> JsonChanged { get; set; }

    /// <summary>
    ///     Callback when an error occurs.
    /// </summary>
    [Parameter]
    public EventCallback<JsonErrorEventArgs> OnError { get; set; }

    /// <summary>
    ///     Custom context menu actions to add.
    /// </summary>
    [Parameter]
    public IReadOnlyList<MokaJsonContextAction>? ContextMenuActions { get; set; }

    /// <summary>
    ///     Style of expand/collapse toggle indicators. Default is <see cref = "MokaJsonToggleStyle.Triangle"/>.
    /// </summary>
    [Parameter]
    public MokaJsonToggleStyle ToggleStyle { get; set; } = MokaJsonToggleStyle.Triangle;

    /// <summary>
    ///     Size of expand/collapse toggle indicators. Default is <see cref = "MokaJsonToggleSize.Small"/>.
    /// </summary>
    [Parameter]
    public MokaJsonToggleSize ToggleSize { get; set; } = MokaJsonToggleSize.Small;

    /// <summary>
    ///     Initial collapse behavior when a document is loaded. Default is <see cref = "MokaJsonCollapseMode.Depth"/>.
    /// </summary>
    [Parameter]
    public MokaJsonCollapseMode CollapseMode { get; set; } = MokaJsonCollapseMode.Depth;

    /// <summary>
    ///     Whether long values wrap to the next line. Default is <c>true</c>.
    /// </summary>
    [Parameter]
    public bool WordWrap { get; set; } = true;

    /// <summary>
    ///     Whether to show child count (e.g. "13 items") on collapsed containers. Default is <c>true</c>.
    /// </summary>
    [Parameter]
    public bool ShowChildCount { get; set; } = true;

    /// <summary>
    ///     How toolbar buttons are displayed. Overrides <see cref = "MokaJsonViewerOptions.DefaultToolbarMode"/>.
    ///     When <c>null</c>, the global default from DI is used.
    /// </summary>
    [Parameter]
    public MokaJsonToolbarMode? ToolbarMode { get; set; }

    /// <summary>
    ///     Extra content to render in the toolbar.
    /// </summary>
    [Parameter]
    public RenderFragment? ToolbarExtra { get; set; }

    /// <summary>
    ///     Whether the settings gear button is shown in the toolbar.
    ///     When <c>null</c>, the global default from DI (<see cref = "MokaJsonViewerOptions.ShowSettingsButton"/>) is used.
    /// </summary>
    [Parameter]
    public bool? ShowSettingsButton { get; set; }

    /// <summary>
    ///     Additional HTML attributes to apply to the root element.
    /// </summary>
    [Parameter(CaptureUnmatchedValues = true)]
    public Dictionary<string, object>? AdditionalAttributes { get; set; }

#endregion
#region State Fields
    private ElementReference _viewerRef;
    private JsonDocumentManager? _documentManager;
#pragma warning disable CA1859 // Intentionally using interface to support future LazyJsonDocumentSource

    private IJsonDocumentSource? _documentSource;
#pragma warning restore CA1859
    private bool _isLazyMode;
    private readonly JsonTreeFlattener _treeFlattener = new();
    private readonly JsonSearchEngine _searchEngine = new();
    private MokaJsonContextMenu? _contextMenu;
    private IReadOnlyList<MokaJsonContextAction>? _previousContextMenuActions;
    private List<FlattenedJsonNode>? _flatNodes;
    private int _selectedDepth;
    private bool _isLoaded;
    private bool _isLoading;
    private bool _cssReady;
    private bool _isFormatted = true;
    private string? _errorMessage;
    private string? _previousJson;
    private Stream? _previousStream;
    /// <summary>
    ///     When non-null, the tree is scoped to show only the subtree at this JSON Pointer path.
    ///     Null means the full document is displayed.
    /// </summary>
    private string? _scopedPath;
    private bool _showSearch;
    private string? _searchQuery;
    private bool _searchCaseSensitive;
    private bool _searchUseRegex;
    private bool _contextMenuVisible;
    private MokaJsonNodeContext? _contextMenuNodeContext;
    private List<MokaJsonContextAction> _allContextActions = [];
    private bool _showSettings;
    private double _settingsLeft;
    private double _settingsTop;
    private string? _documentSize;
    private int _nodeCount;
    private int _maxDepth;
    private string? _parseTimeMs;
    private bool _isValid = true;
    private string? _validationError;
    private CancellationTokenSource? _statsCts;
    private CancellationTokenSource? _treeCts;
    private Timer? _searchDebounceTimer;
    private bool _disposed;
    private bool _isBusy;
    private string? _busyMessage;
    private string? _toastMessage;
    private Timer? _toastTimer;
    private InlineEditState? _activeEdit;
    private EditHistory? _editHistory;
    /// <summary>
    ///     Debug stats from the lazy source, set only when MOKA_DEBUG_LAZY env var is set.
    ///     Used by Moka.Blazor.Json.Diagnostics overlay.
    /// </summary>
    public LazyDebugStats? DebugStats { get; private set; }
#endregion
#region Computed Properties
    private string HeightStyle => $"height: {Height}";
    private string ComputedStyle => _cssReady || _isLoading ? HeightStyle : $"visibility:hidden;{HeightStyle}";
    private MokaJsonToolbarMode EffectiveToolbarMode => ToolbarMode ?? OptionsAccessor.Value.DefaultToolbarMode;
    private bool EffectiveShowSettingsButton => ShowSettingsButton ?? OptionsAccessor.Value.ShowSettingsButton;
    /// <summary>
    ///     Effective read-only state: true if the ReadOnly parameter is set OR if the
    ///     document source doesn't support editing (lazy mode).
    /// </summary>
    private bool EffectiveReadOnly => ReadOnly || _documentSource is { SupportsEditing: false };
    private string ThemeAttribute => Theme switch
    {
        MokaJsonTheme.Light => "light",
        MokaJsonTheme.Dark => "dark",
        MokaJsonTheme.Inherit => "",
        _ => "light" // Auto defaults to light, JS will switch if needed
    };

#endregion
#region Lifecycle
    /// <inheritdoc/>
    protected override void OnInitialized() => BuildContextActions();
    /// <inheritdoc/>
    protected override void OnAfterRender(bool firstRender)
    {
        if (firstRender && !_cssReady)
        {
            _cssReady = true;
            StateHasChanged();
        }
    }

    /// <inheritdoc/>
    protected override async Task OnParametersSetAsync()
    {
        if (ContextMenuActions != _previousContextMenuActions)
        {
            _previousContextMenuActions = ContextMenuActions;
            BuildContextActions();
        }

        if (Json != _previousJson || JsonStream != _previousStream)
        {
            _previousJson = Json;
            _previousStream = JsonStream;
            await LoadJsonAsync();
        }
    }

    private async Task LoadJsonAsync()
    {
        _errorMessage = null;
        _isLoaded = false;
        _isLoading = true;
        _flatNodes = null;
        // Clear stale search state from previous document
        _searchEngine.Clear();
        _treeFlattener.ClearSearchMatches();
        _scopedPath = null;
        // Yield FIRST so Blazor renders the loading spinner before any work begins.
        // Without this yield the component blocks through disposal + parse without
        // ever showing the loading state.
        StateHasChanged();
        await Task.Yield();
        try
        {
            // Dispose previous source/manager (may be slow for large lazy caches)
            if (_documentSource is not null)
            {
                await _documentSource.DisposeAsync();
                _documentSource = null;
            }
            else if (_documentManager is not null)
            {
                await _documentManager.DisposeAsync();
            }

            _documentManager = null;
            _isLazyMode = false;
            DebugStats = null;
            long lazyThreshold = OptionsAccessor.Value.LazyParsingThresholdBytes;
            ILogger lazyLogger = LoggerFactory.CreateLogger<LazyJsonDocumentSource>();
            if (!string.IsNullOrWhiteSpace(Json))
            {
                string json = Json;
                long byteCount = json.Length <= 1_000_000 ? Encoding.UTF8.GetByteCount(json) : await Task.Run(() => Encoding.UTF8.GetByteCount(json));
                if (byteCount > OptionsAccessor.Value.MaxDocumentSizeBytes)
                {
                    throw new InvalidOperationException($"Document size ({JsonDocumentManager.FormatBytes(byteCount)}) exceeds the maximum allowed size ({JsonDocumentManager.FormatBytes(OptionsAccessor.Value.MaxDocumentSizeBytes)}).");
                }

                if (byteCount > lazyThreshold)
                {
                    LazyJsonDocumentSource lazySource = await LazyJsonDocumentSource.CreateFromStringAsync(json, lazyLogger, OptionsAccessor.Value);
                    _documentSource = lazySource;
                    _isLazyMode = true;
                    DebugStats = lazySource.DebugStats;
                }
                else
                {
                    var manager = new JsonDocumentManager(LoggerFactory.CreateLogger<JsonDocumentManager>(), OptionsAccessor);
                    // Always offload parsing to a background thread so the UI stays responsive.
                    // Even small documents benefit since JsonDocument.Parse is synchronous.
                    await Task.Run(() => manager.ParseAsync(json));
                    _documentManager = manager;
                    _documentSource = manager;
                }
            }
            else if (JsonStream is not null)
            {
                // Ensure the stream is at the beginning — callers may pass a reused stream
                if (JsonStream.CanSeek && JsonStream.Position != 0)
                {
                    JsonStream.Position = 0;
                }

                // Check size if seekable
                if (JsonStream.CanSeek && JsonStream.Length > OptionsAccessor.Value.MaxDocumentSizeBytes)
                {
                    throw new InvalidOperationException($"Document size ({JsonDocumentManager.FormatBytes(JsonStream.Length)}) exceeds the maximum allowed size ({JsonDocumentManager.FormatBytes(OptionsAccessor.Value.MaxDocumentSizeBytes)}).");
                }

                if (JsonStream.CanSeek && JsonStream.Length > lazyThreshold)
                {
                    LazyJsonDocumentSource lazySource = await LazyJsonDocumentSource.CreateAsync(JsonStream, lazyLogger, OptionsAccessor.Value);
                    _documentSource = lazySource;
                    _isLazyMode = true;
                    DebugStats = lazySource.DebugStats;
                }
                else
                {
                    var manager = new JsonDocumentManager(LoggerFactory.CreateLogger<JsonDocumentManager>(), OptionsAccessor);
                    // Always offload stream parsing to a background thread.
                    Stream stream = JsonStream;
                    await Task.Run(() => manager.ParseAsync(stream));
                    _documentManager = manager;
                    _documentSource = manager;
                }
            }
            else
            {
                _isLoading = false;
                return;
            }

            if (_documentSource is null)
            {
                _isLoading = false;
                return;
            }

            IJsonDocumentSource source = _documentSource;
            int expandDepth = CollapseMode switch
            {
                MokaJsonCollapseMode.Root => 0,
                MokaJsonCollapseMode.Expanded => -1,
                _ => MaxDepthExpanded
            };
            // Always offload tree expansion and flattening to a background thread
            // so the UI remains responsive during initial load.
            _flatNodes = await Task.Run(() =>
            {
                _treeFlattener.ExpandToDepth(source, expandDepth);
                return _treeFlattener.Flatten(source);
            });
            _documentSize = JsonDocumentManager.FormatBytes(source.DocumentSizeBytes);
            _parseTimeMs = $"{source.ParseTime.TotalMilliseconds:F1} ms";
            // Always compute stats on a background thread to keep the UI responsive.
            _nodeCount = -1;
            _maxDepth = -1;
            if (_statsCts is not null)
            {
                await _statsCts.CancelAsync();
                _statsCts.Dispose();
            }

            CancellationTokenSource cts = _statsCts = new CancellationTokenSource();
            _ = Task.Run(() =>
            {
                try
                {
                    if (cts.Token.IsCancellationRequested)
                    {
                        return;
                    }

                    int nc = source.CountNodes();
                    int md = source.GetMaxDepth();
                    if (cts.Token.IsCancellationRequested)
                    {
                        return;
                    }

                    _ = InvokeAsync(() =>
                    {
                        if (cts.Token.IsCancellationRequested)
                        {
                            return;
                        }

                        _nodeCount = nc;
                        _maxDepth = md;
                        StateHasChanged();
                    });
                }
                catch (Exception ex)when (ex is ObjectDisposedException or InvalidOperationException or OperationCanceledException && (cts.Token.IsCancellationRequested || _disposed))
                {
                // Source was disposed or operation was cancelled — ignore
                }
            }, cts.Token);
            _isValid = true;
            _validationError = null;
            _isLoaded = true;
            _isLoading = false;
            // Rebuild context actions since EffectiveReadOnly may have changed
            BuildContextActions();
        }
        catch (JsonException ex)
        {
            _isValid = false;
            _validationError = ex.Message;
            _errorMessage = ex.Message;
            _isLoading = false;
            await RaiseError(ex.Message, ex, ex.BytePositionInLine, ex.LineNumber);
        }
        catch (ObjectDisposedException)
        {
            // A concurrent LoadJsonAsync disposed the previous document source
            // while a background task was still using it — safe to ignore since
            // the newer load will take over.
            _isLoading = false;
        }
        catch (Exception ex)when (ex is not OperationCanceledException)
        {
            _errorMessage = ex.Message;
            _isLoading = false;
            await RaiseError(ex.Message, ex);
        }
    }

    private async Task ReloadFromJsonAsync(string json)
    {
        // Temporarily set Json so LoadJsonAsync picks it up, then restore.
        string? originalJson = Json;
        Json = json;
        await LoadJsonAsync();
        // Don't leave the parameter mutated — restore original so parent stays in control
        Json = originalJson;
    }

    private void RefreshFlatNodes()
    {
        if (!_isLoaded || _documentSource is null)
        {
            return;
        }

        if (_scopedPath is not null)
        {
            try
            {
                _flatNodes = _treeFlattener.FlattenScoped(_documentSource, _scopedPath);
            }
            catch (KeyNotFoundException)
            {
                // Scoped path no longer valid, reset to root
                _scopedPath = null;
                _flatNodes = _treeFlattener.Flatten(_documentSource);
            }
        }
        else
        {
            _flatNodes = _treeFlattener.Flatten(_documentSource);
        }

        StateHasChanged();
    }

    private async Task RefreshFlatNodesAsync()
    {
        if (!_isLoaded || _documentSource is null)
        {
            return;
        }

        IJsonDocumentSource source = _documentSource;
        string? scopedPath = _scopedPath;
        List<FlattenedJsonNode> nodes;
        try
        {
            nodes = await Task.Run(() =>
            {
                if (scopedPath is not null)
                {
                    try
                    {
                        return _treeFlattener.FlattenScoped(source, scopedPath);
                    }
                    catch (KeyNotFoundException)
                    {
                        return _treeFlattener.Flatten(source);
                    }
                }

                return _treeFlattener.Flatten(source);
            });
        }
        catch (ObjectDisposedException)
        {
            // Source was disposed by a concurrent operation (e.g., new document loaded) — ignore
            return;
        }

        // If scoped path was invalid, reset it on the UI thread
        if (scopedPath is not null && nodes.Count > 0 && nodes[0].Path != scopedPath)
        {
            _scopedPath = null;
        }

        _flatNodes = nodes;
        StateHasChanged();
    }

#endregion
#region Toolbar Handlers
    private void ToggleSearch()
    {
        _showSearch = !_showSearch;
        if (!_showSearch)
        {
            _searchQuery = null;
            _searchEngine.Clear();
            _treeFlattener.ClearSearchMatches();
            RefreshFlatNodes();
        }
    }

    private async Task HandleExpandAll()
    {
        if (_documentSource is null)
        {
            return;
        }

        IJsonDocumentSource source = _documentSource;
        // Cancel any in-flight tree operation
        await CancelTreeOperationAsync();
        _isBusy = true;
        _busyMessage = "Expanding all nodes...";
        StateHasChanged();
        CancellationTokenSource cts = _treeCts = new CancellationTokenSource();
        try
        {
            await Task.Run(() => _treeFlattener.ExpandAll(source), cts.Token);
            if (!cts.Token.IsCancellationRequested)
            {
                await RefreshFlatNodesAsync();
            }
        }
        catch (Exception ex)when (ex is OperationCanceledException or ObjectDisposedException)
        {
        }
        finally
        {
            if (_treeCts == cts)
            {
                _isBusy = false;
                _busyMessage = null;
                StateHasChanged();
            }
        }
    }

    private async Task HandleCollapseAll()
    {
        if (_documentSource is null)
        {
            return;
        }

        // Cancel any in-flight tree operation
        await CancelTreeOperationAsync();
        _isBusy = true;
        _busyMessage = "Collapsing...";
        StateHasChanged();
        CancellationTokenSource cts = _treeCts = new CancellationTokenSource();
        try
        {
            await Task.Run(() =>
            {
                _treeFlattener.CollapseAll();
                _treeFlattener.Expand("");
            }, cts.Token);
            if (!cts.Token.IsCancellationRequested)
            {
                await RefreshFlatNodesAsync();
            }
        }
        catch (Exception ex)when (ex is OperationCanceledException or ObjectDisposedException)
        {
        }
        finally
        {
            if (_treeCts == cts)
            {
                _isBusy = false;
                _busyMessage = null;
                StateHasChanged();
            }
        }
    }

    private async Task CancelTreeOperationAsync()
    {
        if (_treeCts is not null)
        {
            await _treeCts.CancelAsync();
            _treeCts.Dispose();
            _treeCts = null;
            _isBusy = false;
            _busyMessage = null;
        }
    }

    private void HandleFormatToggle() => _isFormatted = !_isFormatted;
    private async Task HandleCopyAll()
    {
        if (_documentSource is null)
        {
            return;
        }

        // Guard against OOM for very large documents
        if (_documentSource.DocumentSizeBytes > OptionsAccessor.Value.MaxClipboardSizeBytes)
        {
            ShowToast("Document too large to copy. Right-click a node and use \"Scope to This Node\" to copy smaller sections.");
            return;
        }

        string json = _documentSource.GetJsonString(_isFormatted);
        await Interop.CopyToClipboardAsync(json);
    }

    private async Task HandleExport()
    {
        if (_documentSource is null)
        {
            return;
        }

        string json = _documentSource.GetJsonString(_isFormatted);
        string fileName = $"export-{DateTime.Now:yyyyMMdd-HHmmss}.json";
        await Interop.DownloadFileAsync(fileName, json);
    }

#endregion
#region Search Handlers
    private async Task HandleSearchQueryChanged(string query)
    {
        _searchQuery = query;
        int debounceMs = OptionsAccessor.Value.SearchDebounceMs;
        if (debounceMs <= 0)
        {
            await ExecuteSearchAsync();
            return;
        }

        _searchDebounceTimer?.Dispose();
        _searchDebounceTimer = new Timer(_ =>
        {
            if (_disposed)
            {
                return;
            }

            _ = InvokeAsync(ExecuteSearchAsync);
        }, null, debounceMs, Timeout.Infinite);
    }

    private void HandleSearchNext()
    {
        string? path = _searchEngine.NextMatch();
        if (path is not null)
        {
            EnsurePathExpanded(path);
            _treeFlattener.SetSearchMatches(_searchEngine.MatchPaths, _searchEngine.ActiveMatchPath);
            RefreshFlatNodes();
        }
    }

    private void HandleSearchPrevious()
    {
        string? path = _searchEngine.PreviousMatch();
        if (path is not null)
        {
            EnsurePathExpanded(path);
            _treeFlattener.SetSearchMatches(_searchEngine.MatchPaths, _searchEngine.ActiveMatchPath);
            RefreshFlatNodes();
        }
    }

    private async Task HandleCaseSensitiveChanged(bool value)
    {
        _searchCaseSensitive = value;
        await ExecuteSearchAsync();
    }

    private async Task HandleRegexChanged(bool value)
    {
        _searchUseRegex = value;
        await ExecuteSearchAsync();
    }

    private async Task ExecuteSearchAsync()
    {
        if (_documentSource is null || string.IsNullOrEmpty(_searchQuery))
        {
            _searchEngine.Clear();
            _treeFlattener.ClearSearchMatches();
            await RefreshFlatNodesAsync();
            return;
        }

        IJsonDocumentSource source = _documentSource;
        string query = _searchQuery;
        var options = new JsonSearchOptions
        {
            CaseSensitive = _searchCaseSensitive,
            UseRegex = _searchUseRegex
        };
        try
        {
            await Task.Run(() => _searchEngine.Search(source, query, options));
        }
        catch (ObjectDisposedException)
        {
            return;
        }

        if (_searchEngine.ActiveMatchPath is not null)
        {
            EnsurePathExpanded(_searchEngine.ActiveMatchPath);
        }

        _treeFlattener.SetSearchMatches(_searchEngine.MatchPaths, _searchEngine.ActiveMatchPath);
        await RefreshFlatNodesAsync();
    }

#endregion
#region Tree Interaction Handlers
    private async Task HandleToggle(string path)
    {
        if (_documentSource is null)
        {
            return;
        }

        _treeFlattener.ToggleExpand(path);
        try
        {
            await RefreshFlatNodesAsync();
        }
        catch (ObjectDisposedException)
        {
        }
    }

    private async Task HandleSelect(string path)
    {
        SelectedPath = path;
        if (_documentSource is not null)
        {
            try
            {
                JsonElement element = _documentSource.GetElement(path);
                string[] segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
                _selectedDepth = segments.Length;
                string? propName = segments.Length > 0 ? JsonPointerHelper.UnescapeSegment(segments[^1]) : null;
                bool isContainer = element.ValueKind is JsonValueKind.Object or JsonValueKind.Array;
                string rawText = isContainer ? "" : element.GetRawText();
                string preview = isContainer ? $"{_documentSource.GetChildCount(path)} items" : TruncatePreview(rawText);
                await OnNodeSelected.InvokeAsync(new JsonNodeSelectedEventArgs { Path = path, Depth = _selectedDepth, ValueKind = element.ValueKind, PropertyName = propName, RawValue = rawText, RawValuePreview = preview });
            }
            catch (KeyNotFoundException)
            {
            // Path no longer valid
            }
        }

        StateHasChanged();
    }

    private async Task HandleContextMenuRequest((string Path, double ClientX, double ClientY) args)
    {
        if (_documentSource is null)
        {
            return;
        }

        try
        {
            JsonElement element = _documentSource.GetElement(args.Path);
            string[] segments = args.Path.Split('/', StringSplitOptions.RemoveEmptyEntries);
            string? propName = segments.Length > 0 ? JsonPointerHelper.UnescapeSegment(segments[^1]) : null;
            string rawText = element.GetRawText();
            bool isContainer = element.ValueKind is JsonValueKind.Object or JsonValueKind.Array;
            string preview = isContainer ? $"{_documentSource.GetChildCount(args.Path)} items" : TruncatePreview(rawText);
            _contextMenuNodeContext = new MokaJsonNodeContext
            {
                Path = args.Path,
                Depth = segments.Length,
                ValueKind = element.ValueKind,
                PropertyName = propName,
                RawValue = rawText,
                RawValuePreview = preview,
                Viewer = this
            };
            _contextMenuVisible = true;
            StateHasChanged();
            if (_contextMenu is not null)
            {
                await _contextMenu.ShowAsync(args.ClientX, args.ClientY);
            }
        }
        catch (KeyNotFoundException)
        {
        }
    }

    private void HandleDoubleClick(string path)
    {
        if (EffectiveReadOnly || _documentManager is null)
        {
            return;
        }

        try
        {
            JsonElement element = _documentManager.NavigateToElement(path);
            // Only primitives are directly editable via double-click
            if (element.ValueKind is JsonValueKind.Object or JsonValueKind.Array)
            {
                return;
            }

            string rawValue = element.ValueKind == JsonValueKind.String ? element.GetString() ?? "" : element.GetRawText();
            _activeEdit = new InlineEditState
            {
                Path = path,
                Target = InlineEditTarget.Value,
                OriginalValue = rawValue,
                CurrentValue = rawValue,
                ValueKind = element.ValueKind
            };
            StateHasChanged();
        }
        catch (KeyNotFoundException)
        {
        }
    }

    private void StartKeyRename(string path, string currentKey)
    {
        _activeEdit = new InlineEditState
        {
            Path = path,
            Target = InlineEditTarget.Key,
            OriginalValue = currentKey,
            CurrentValue = currentKey,
            ValueKind = JsonValueKind.String
        };
        StateHasChanged();
    }

    private async Task HandleEditCommit(InlineEditResult result)
    {
        if (_activeEdit is null || _documentManager is null)
        {
            return;
        }

        if (!result.Committed)
        {
            _activeEdit = null;
            StateHasChanged();
            return;
        }

        // Don't commit if value hasn't changed
        if (result.NewValue == _activeEdit.OriginalValue)
        {
            _activeEdit = null;
            StateHasChanged();
            return;
        }

        string editPath = _activeEdit.Path;
        InlineEditTarget editTarget = _activeEdit.Target;
        string? oldValue = _activeEdit.OriginalValue;
        try
        {
            string newJson;
            JsonChangeType changeType;
            if (editTarget == InlineEditTarget.Value)
            {
                string? error = JsonEditValidator.ValidateValue(result.NewValue, _activeEdit.ValueKind);
                if (error is not null)
                {
                    _activeEdit.ValidationError = error;
                    StateHasChanged();
                    return;
                }

                string jsonLiteral = _activeEdit.ValueKind switch
                {
                    JsonValueKind.String => JsonSerializer.Serialize(result.NewValue),
                    JsonValueKind.Number => result.NewValue,
                    JsonValueKind.True or JsonValueKind.False => result.NewValue == "true" ? "true" : "false",
                    JsonValueKind.Null => "null",
                    _ => result.NewValue
                };
                newJson = _documentManager.ReplaceValueAtPath(editPath, jsonLiteral);
                changeType = JsonChangeType.ValueChanged;
            }
            else
            {
                newJson = _documentManager.RenameKeyAtPath(editPath, result.NewValue);
                changeType = JsonChangeType.KeyRenamed;
            }

            _editHistory ??= new EditHistory();
            _editHistory.PushSnapshot(_documentManager.GetJsonString());
            _activeEdit = null;
            _previousJson = newJson;
            await ReloadFromJsonAsync(newJson);
            await JsonChanged.InvokeAsync(newJson);
            await OnJsonChanged.InvokeAsync(new JsonChangeEventArgs { FullJson = newJson, Path = editPath, ChangeType = changeType, OldValue = oldValue, NewValue = result.NewValue });
        }
        catch (Exception ex)when (ex is JsonException or InvalidOperationException or KeyNotFoundException)
        {
            _activeEdit!.ValidationError = ex.Message;
            StateHasChanged();
        }
    }

    private void HandleEditCancel()
    {
        _activeEdit = null;
        StateHasChanged();
    }

    private async Task DeleteNodeAtPath(string path)
    {
        if (_documentManager is null)
        {
            return;
        }

        try
        {
            _editHistory ??= new EditHistory();
            _editHistory.PushSnapshot(_documentManager.GetJsonString());
            string newJson = _documentManager.RemoveNodeAtPath(path);
            _previousJson = newJson;
            _activeEdit = null;
            await ReloadFromJsonAsync(newJson);
            await JsonChanged.InvokeAsync(newJson);
            await OnJsonChanged.InvokeAsync(new JsonChangeEventArgs { FullJson = newJson, Path = path, ChangeType = JsonChangeType.NodeRemoved });
        }
        catch (Exception ex)when (ex is InvalidOperationException or KeyNotFoundException)
        {
            _errorMessage = ex.Message;
            StateHasChanged();
        }
    }

    private async Task AddPropertyAtPath(string parentPath)
    {
        if (_documentManager is null)
        {
            return;
        }

        try
        {
            _editHistory ??= new EditHistory();
            _editHistory.PushSnapshot(_documentManager.GetJsonString());
            string newJson = _documentManager.AddNodeAtPath(parentPath, null, "null");
            _previousJson = newJson;
            await ReloadFromJsonAsync(newJson);
            await JsonChanged.InvokeAsync(newJson);
            await OnJsonChanged.InvokeAsync(new JsonChangeEventArgs { FullJson = newJson, Path = parentPath, ChangeType = JsonChangeType.NodeAdded });
        }
        catch (Exception ex)when (ex is InvalidOperationException or KeyNotFoundException)
        {
            _errorMessage = ex.Message;
            StateHasChanged();
        }
    }

    private async Task AddElementAtPath(string parentPath)
    {
        if (_documentManager is null)
        {
            return;
        }

        try
        {
            _editHistory ??= new EditHistory();
            _editHistory.PushSnapshot(_documentManager.GetJsonString());
            string newJson = _documentManager.AddNodeAtPath(parentPath, null, "null");
            _previousJson = newJson;
            await ReloadFromJsonAsync(newJson);
            await JsonChanged.InvokeAsync(newJson);
            await OnJsonChanged.InvokeAsync(new JsonChangeEventArgs { FullJson = newJson, Path = parentPath, ChangeType = JsonChangeType.NodeAdded });
        }
        catch (Exception ex)when (ex is InvalidOperationException or KeyNotFoundException)
        {
            _errorMessage = ex.Message;
            StateHasChanged();
        }
    }

    private void DismissContextMenu()
    {
        _contextMenuVisible = false;
        _contextMenuNodeContext = null;
        StateHasChanged();
    }

#region Settings Panel
    private void ToggleSettings()
    {
        _showSettings = !_showSettings;
        if (_showSettings)
        {
            // Position below the toolbar, aligned right
            _settingsLeft = 0;
            _settingsTop = 0;
        }
    }

    private void DismissSettings()
    {
        _showSettings = false;
        StateHasChanged();
    }

    private void HandleSettingsThemeChanged(MokaJsonTheme value) => Theme = value;
    private void HandleSettingsToolbarModeChanged(MokaJsonToolbarMode value) => ToolbarMode = value;
    private void HandleSettingsToggleStyleChanged(MokaJsonToggleStyle value)
    {
        ToggleStyle = value;
        StateHasChanged();
    }

    private void HandleSettingsToggleSizeChanged(MokaJsonToggleSize value)
    {
        ToggleSize = value;
        StateHasChanged();
    }

    private void HandleSettingsShowLineNumbersChanged(bool value)
    {
        ShowLineNumbers = value;
        StateHasChanged();
    }

    private void HandleSettingsWordWrapChanged(bool value) => WordWrap = value;
    private void HandleSettingsShowBreadcrumbChanged(bool value) => ShowBreadcrumb = value;
    private void HandleSettingsShowBottomBarChanged(bool value) => ShowBottomBar = value;
    private void HandleSettingsShowChildCountChanged(bool value)
    {
        ShowChildCount = value;
        StateHasChanged();
    }

    private void HandleSettingsMaxDepthChanged(int value) => MaxDepthExpanded = value;
    private void HandleSettingsCollapseModeChanged(MokaJsonCollapseMode value) => CollapseMode = value;
    private void HandleSettingsReadOnlyChanged(bool value) => ReadOnly = value;
    private void HandleSettingsSearchCaseSensitiveChanged(bool value) => _searchCaseSensitive = value;
    private void HandleSettingsSearchUseRegexChanged(bool value) => _searchUseRegex = value;
#endregion
    private async Task HandleBreadcrumbNavigate(string path)
    {
        // If clicking root or a path above the current scope, unscope
        if (_scopedPath is not null)
        {
            if (string.IsNullOrEmpty(path) || !path.StartsWith(_scopedPath, StringComparison.Ordinal))
            {
                UnscopeToRoot();
            }
        }

        await HandleSelect(path);
    }

    private async Task HandleKeyDown(KeyboardEventArgs e)
    {
        if (e is { CtrlKey: true, Key: "f" })
        {
            ToggleSearch();
        }
        else if (e is { CtrlKey: true, Key: "z" } && !EffectiveReadOnly)
        {
            Undo();
        }
        else if (e is { CtrlKey: true, Key: "y" } && !EffectiveReadOnly)
        {
            Redo();
        }
    }

#endregion
#region Context Action Builders
    private void BuildContextActions()
    {
        _allContextActions = [new MokaJsonContextAction
        {
            Id = "copy-value",
            Label = "Copy Value",
            IconSvg = MokaJsonIcons.SvgString(MokaJsonIcons.Copy),
            ShortcutHint = "Ctrl+C",
            Order = 10,
            OnExecute = async ctx =>
            {
                string value = ctx.RawValue;
                if (string.IsNullOrEmpty(value) && _documentSource is not null)
                {
                    // Deferred for large containers — serialize on background thread
                    try
                    {
                        value = await Task.Run(() => _documentSource.GetElement(ctx.Path).GetRawText());
                    }
                    catch (OutOfMemoryException)
                    {
                        ShowToast("Value too large to copy. Use \"Scope to This Node\" to copy smaller sections.");
                        return;
                    }
                }

                if (value.Length > OptionsAccessor.Value.MaxClipboardSizeBytes)
                {
                    ShowToast("Value too large to copy. Use \"Scope to This Node\" to copy smaller sections.");
                    return;
                }

                await Interop.CopyToClipboardAsync(value);
            }
        }, new MokaJsonContextAction
        {
            Id = "copy-path",
            Label = "Copy Path",
            IconSvg = MokaJsonIcons.SvgString(MokaJsonIcons.CopyPath),
            Order = 20,
            OnExecute = async ctx => await Interop.CopyToClipboardAsync(JsonPathConverter.ToDotNotation(ctx.Path))
        }, new MokaJsonContextAction
        {
            Id = "expand-children",
            Label = "Expand All Children",
            IconSvg = MokaJsonIcons.SvgString(MokaJsonIcons.Expand),
            Order = 100,
            HasSeparatorBefore = true,
            IsVisible = ctx => ctx.ValueKind is JsonValueKind.Object or JsonValueKind.Array,
            OnExecute = async ctx => await ExpandSubtree(ctx.Path)
        }, new MokaJsonContextAction
        {
            Id = "collapse-children",
            Label = "Collapse All Children",
            IconSvg = MokaJsonIcons.SvgString(MokaJsonIcons.Collapse),
            Order = 110,
            IsVisible = ctx => ctx.ValueKind is JsonValueKind.Object or JsonValueKind.Array,
            OnExecute = ctx =>
            {
                CollapseSubtree(ctx.Path);
                return ValueTask.CompletedTask;
            }
        }, new MokaJsonContextAction
        {
            Id = "scope-to-node",
            Label = "Scope to This Node",
            IconSvg = MokaJsonIcons.SvgString(MokaJsonIcons.Scope),
            Order = 200,
            HasSeparatorBefore = true,
            IsVisible = ctx => ctx.ValueKind is JsonValueKind.Object or JsonValueKind.Array,
            OnExecute = ctx =>
            {
                ScopeToNode(ctx.Path);
                return ValueTask.CompletedTask;
            }
        }, new MokaJsonContextAction
        {
            Id = "sort-keys",
            Label = "Sort Keys",
            IconSvg = MokaJsonIcons.SvgString(MokaJsonIcons.Sort),
            Order = 300,
            HasSeparatorBefore = true,
            IsVisible = ctx => ctx.ValueKind is JsonValueKind.Object,
            OnExecute = async ctx => await SortKeysAtPath(ctx.Path, false)
        }, new MokaJsonContextAction
        {
            Id = "sort-keys-recursive",
            Label = "Sort Keys (Recursive)",
            IconSvg = MokaJsonIcons.SvgString(MokaJsonIcons.Sort),
            Order = 310,
            IsVisible = ctx => ctx.ValueKind is JsonValueKind.Object,
            OnExecute = async ctx => await SortKeysAtPath(ctx.Path, true)
        }

        ];
        if (!EffectiveReadOnly)
        {
            _allContextActions.Add(new MokaJsonContextAction { Id = "edit-value", Label = "Edit Value", IconSvg = MokaJsonIcons.SvgString(MokaJsonIcons.Edit), ShortcutHint = "Dbl-click", Order = 400, HasSeparatorBefore = true, IsVisible = ctx => ctx.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array), OnExecute = ctx =>
            {
                HandleDoubleClick(ctx.Path);
                return ValueTask.CompletedTask;
            } });
            _allContextActions.Add(new MokaJsonContextAction { Id = "rename-key", Label = "Rename Key", IconSvg = MokaJsonIcons.SvgString(MokaJsonIcons.Rename), ShortcutHint = "F2", Order = 410, IsVisible = ctx => ctx.PropertyName is not null, OnExecute = ctx =>
            {
                StartKeyRename(ctx.Path, ctx.PropertyName!);
                return ValueTask.CompletedTask;
            } });
            _allContextActions.Add(new MokaJsonContextAction { Id = "delete-node", Label = "Delete", IconSvg = MokaJsonIcons.SvgString(MokaJsonIcons.Delete), ShortcutHint = "Del", Order = 420, IsVisible = ctx => !string.IsNullOrEmpty(ctx.Path) && ctx.Path != "/", OnExecute = async ctx => await DeleteNodeAtPath(ctx.Path) });
            _allContextActions.Add(new MokaJsonContextAction { Id = "add-property", Label = "Add Property", IconSvg = MokaJsonIcons.SvgString(MokaJsonIcons.Add), Order = 430, IsVisible = ctx => ctx.ValueKind == JsonValueKind.Object, OnExecute = async ctx => await AddPropertyAtPath(ctx.Path) });
            _allContextActions.Add(new MokaJsonContextAction { Id = "add-element", Label = "Add Element", IconSvg = MokaJsonIcons.SvgString(MokaJsonIcons.Add), Order = 440, IsVisible = ctx => ctx.ValueKind == JsonValueKind.Array, OnExecute = async ctx => await AddElementAtPath(ctx.Path) });
        }

        if (ContextMenuActions is not null)
        {
            _allContextActions.AddRange(ContextMenuActions);
        }
    }

    private async Task SortKeysAtPath(string path, bool recursive)
    {
        if (_documentSource is null || _documentManager is null)
        {
            return;
        }

        try
        {
            JsonElement element = _documentSource.GetElement(path);
            if (element.ValueKind != JsonValueKind.Object)
            {
                return;
            }

            if (!EffectiveReadOnly)
            {
                _editHistory ??= new EditHistory();
                _editHistory.PushSnapshot(_documentManager.GetJsonString());
            }

            // Sort the subtree
            string sortedSubtreeJson = recursive ? JsonSorter.SortKeysRecursive(element) : JsonSorter.SortKeys(element);
            // Rebuild the entire document with the sorted subtree
            string newJson = ReplaceSubtreeJson(path, sortedSubtreeJson);
            // Re-parse the document with sorted keys.
            // Update _previousJson so OnParametersSetAsync won't detect a mismatch
            // if the parent re-renders with the old Json value.
            _previousJson = newJson;
            await ReloadFromJsonAsync(newJson);
            await JsonChanged.InvokeAsync(newJson);
            await OnJsonChanged.InvokeAsync(new JsonChangeEventArgs { FullJson = newJson, Path = path, ChangeType = JsonChangeType.KeysSorted });
        }
        catch (KeyNotFoundException)
        {
        }
    }

    private string ReplaceSubtreeJson(string path, string newSubtreeJson)
    {
        if (_documentSource is null)
        {
            return newSubtreeJson;
        }

        if (string.IsNullOrEmpty(path) || path == "/")
        // Replacing root entirely
        {
            return newSubtreeJson;
        }

        // Rebuild the full document with the sorted subtree inserted at the given path
        JsonElement rootElement = _documentSource.GetElement("");
        using var stream = new MemoryStream();
        using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true });
        using var replacementDoc = JsonDocument.Parse(newSubtreeJson);
        string[] segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
        WriteWithReplacement(rootElement, writer, segments, 0, replacementDoc.RootElement);
        writer.Flush();
        return Encoding.UTF8.GetString(stream.GetBuffer().AsSpan(0, checked((int)stream.Length)));
    }

    private static void WriteWithReplacement(JsonElement current, Utf8JsonWriter writer, string[] pathSegments, int segmentIndex, JsonElement replacement)
    {
        if (segmentIndex >= pathSegments.Length)
        {
            // This is the target node - write the replacement
            replacement.WriteTo(writer);
            return;
        }

        string targetSegment = JsonPointerHelper.UnescapeSegment(pathSegments[segmentIndex]);
        switch (current.ValueKind)
        {
            case JsonValueKind.Object:
                writer.WriteStartObject();
                foreach (JsonProperty prop in current.EnumerateObject())
                {
                    writer.WritePropertyName(prop.Name);
                    if (prop.Name == targetSegment)
                    {
                        WriteWithReplacement(prop.Value, writer, pathSegments, segmentIndex + 1, replacement);
                    }
                    else
                    {
                        prop.Value.WriteTo(writer);
                    }
                }

                writer.WriteEndObject();
                break;
            case JsonValueKind.Array:
                writer.WriteStartArray();
                int i = 0;
                foreach (JsonElement item in current.EnumerateArray())
                {
                    if (i.ToString(CultureInfo.InvariantCulture) == targetSegment)
                    {
                        WriteWithReplacement(item, writer, pathSegments, segmentIndex + 1, replacement);
                    }
                    else
                    {
                        item.WriteTo(writer);
                    }

                    i++;
                }

                writer.WriteEndArray();
                break;
            default:
                current.WriteTo(writer);
                break;
        }
    }

#endregion
#region Private Helpers
    private void ScopeToNode(string path)
    {
        if (_documentSource is null)
        {
            return;
        }

        try
        {
            // Verify the path is valid
            JsonValueKind kind = _documentSource.GetValueKind(path);
            if (kind is not (JsonValueKind.Object or JsonValueKind.Array))
            {
                return;
            }

            _scopedPath = path;
            _treeFlattener.Expand(path);
            RefreshFlatNodes();
        }
        catch (KeyNotFoundException)
        {
        }
    }

    private void UnscopeToRoot()
    {
        _scopedPath = null;
        RefreshFlatNodes();
    }

    private async Task ExpandSubtree(string path)
    {
        if (_documentSource is null)
        {
            return;
        }

        IJsonDocumentSource source = _documentSource;
        await CancelTreeOperationAsync();
        _isBusy = true;
        _busyMessage = "Expanding subtree...";
        StateHasChanged();
        CancellationTokenSource cts = _treeCts = new CancellationTokenSource();
        try
        {
            await Task.Run(() => ExpandSubtreeRecursive(source, path), cts.Token);
            if (!cts.Token.IsCancellationRequested)
            {
                await RefreshFlatNodesAsync();
            }
        }
        catch (Exception ex)when (ex is OperationCanceledException or ObjectDisposedException or KeyNotFoundException)
        {
        }
        finally
        {
            if (_treeCts == cts)
            {
                _isBusy = false;
                _busyMessage = null;
                StateHasChanged();
            }
        }
    }

    private void ExpandSubtreeRecursive(IJsonDocumentSource source, string path)
    {
        JsonValueKind kind = source.GetValueKind(path);
        if (kind is not (JsonValueKind.Object or JsonValueKind.Array))
        {
            return;
        }

        _treeFlattener.Expand(path);
        foreach (JsonChildDescriptor child in source.EnumerateChildren(path))
        {
            if (child.ValueKind is JsonValueKind.Object or JsonValueKind.Array)
            {
                ExpandSubtreeRecursive(source, child.Path);
            }
        }
    }

    private void CollapseSubtree(string path)
    {
        // Remove all expanded paths that start with the given path
        var toRemove = _treeFlattener.ExpandedPaths.Where(p => p == path || p.StartsWith(path + "/", StringComparison.Ordinal)).ToList();
        foreach (string p in toRemove)
        {
            _treeFlattener.Collapse(p);
        }

        RefreshFlatNodes();
    }

    private void EnsurePathExpanded(string path)
    {
        // Also expand the root
        _treeFlattener.Expand("");
        string[] segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
        var sb = new StringBuilder(path.Length);
        foreach (string segment in segments)
        {
            sb.Append('/');
            sb.Append(segment);
            string currentPath = sb.ToString();
            if (currentPath != path)
            {
                _treeFlattener.Expand(currentPath);
            }
        }
    }

    private void ShowToast(string message, int durationMs = 5000)
    {
        _toastTimer?.Dispose();
        _toastMessage = message;
        StateHasChanged();
        _toastTimer = new Timer(_ =>
        {
            _ = InvokeAsync(() =>
            {
                _toastMessage = null;
                StateHasChanged();
            });
        }, null, durationMs, Timeout.Infinite);
    }

    private void DismissToast()
    {
        _toastTimer?.Dispose();
        _toastTimer = null;
        _toastMessage = null;
        StateHasChanged();
    }

    private static string TruncatePreview(string raw, int maxLength = 500) => raw.Length > maxLength ? raw[..maxLength] + "..." : raw;
    private async Task RaiseError(string message, Exception? ex = null, long? bytePos = null, long? lineNumber = null)
    {
        await OnError.InvokeAsync(new JsonErrorEventArgs { Message = message, Exception = ex, BytePosition = bytePos, LineNumber = lineNumber });
    }

#endregion
#region IMokaJsonViewer Implementation
    /// <inheritdoc/>
    public async ValueTask NavigateToAsync(string jsonPointer, CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(jsonPointer);
        EnsurePathExpanded(jsonPointer);
        await HandleSelect(jsonPointer);
        await RefreshFlatNodesAsync();
    }

    /// <inheritdoc/>
    public void ExpandToDepth(int depth)
    {
        if (_documentSource is null)
        {
            return;
        }

        _treeFlattener.ExpandToDepth(_documentSource, depth);
        RefreshFlatNodes();
    }

    /// <inheritdoc/>
    public void CollapseAll()
    {
        _treeFlattener.CollapseAll();
        RefreshFlatNodes();
    }

    /// <inheritdoc/>
    public async void ExpandAll() => await HandleExpandAll();
    /// <inheritdoc/>
    public async ValueTask<int> SearchAsync(string query, JsonSearchOptions? options = null, CancellationToken cancellationToken = default)
    {
        if (_documentSource is null)
        {
            return 0;
        }

        return _searchEngine.Search(_documentSource, query, options, cancellationToken);
    }

    /// <inheritdoc/>
    public void NextMatch() => HandleSearchNext();
    /// <inheritdoc/>
    public void PreviousMatch() => HandleSearchPrevious();
    /// <inheritdoc/>
    public void ClearSearch()
    {
        _searchEngine.Clear();
        _treeFlattener.ClearSearchMatches();
        RefreshFlatNodes();
    }

    /// <inheritdoc/>
    public async void Undo()
    {
        if (EffectiveReadOnly || _editHistory is null || !_editHistory.CanUndo)
        {
            return;
        }

        string? snapshot = _editHistory.Undo();
        if (snapshot is null)
        {
            return;
        }

        _activeEdit = null;
        _previousJson = snapshot;
        await ReloadFromJsonAsync(snapshot);
        await JsonChanged.InvokeAsync(snapshot);
    }

    /// <inheritdoc/>
    public async void Redo()
    {
        if (EffectiveReadOnly || _editHistory is null || !_editHistory.CanRedo)
        {
            return;
        }

        string? snapshot = _editHistory.Redo();
        if (snapshot is null)
        {
            return;
        }

        _activeEdit = null;
        _previousJson = snapshot;
        await ReloadFromJsonAsync(snapshot);
        await JsonChanged.InvokeAsync(snapshot);
    }

    /// <inheritdoc/>
    public string GetJson(bool indented = true) => _documentSource?.GetJsonString(indented) ?? string.Empty;
    /// <inheritdoc/>
    public string? SelectedPath { get; private set; }
    /// <inheritdoc/>
    public bool IsEditing => _activeEdit is not null;
#endregion
}
Was this page helpful?