private/Controls/Add-UiDataGridRowContextMenuItems.ps1

function Add-UiDataGridRowContextMenuItems {
    <#
    .SYNOPSIS
        Prepends items to the DataGrid context menu.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Windows.Controls.DataGrid]$DataGrid,

        # IDictionary so [ordered]@{} arrives intact - menu items render in declaration order.
        [Parameter(Mandatory)]
        [System.Collections.IDictionary]$Items
    )

    $menu = $DataGrid.ContextMenu
    if (!$menu) {
        $menu = [System.Windows.Controls.ContextMenu]::new()
        $DataGrid.ContextMenu = $menu
    }

    $rowMenuItems = [System.Collections.Generic.List[object]]::new()
    $insertIndex  = 0

    # Right click target. WPF never moves CurrentCell or selection on rightclick, and a fresh grid's CurrentCell.Item is DependencyProperty.UnsetValue (not $null, that would be too easy) - resolving the target from CurrentCell at click time acted on the last LEFT clicked row, or fed UnsetValue to the action. ContextMenuOpening fires before the menu shows: walk from the click source to the row and remember it. A click outside the current selection also retargets the selection (Explorer convention).
    $clickState = @{ Item = $null }
    $DataGrid.Add_ContextMenuOpening({
        param($sender, $eventArgs)
        $clickState.Item = $null
        $src = $eventArgs.OriginalSource -as [System.Windows.DependencyObject]
        while ($src -and $src -isnot [System.Windows.Controls.DataGridRow]) {
            # Inline Runs and other ContentElements aren't Visuals - fall back to the logical tree for those hops.
            $src = if ($src -is [System.Windows.Media.Visual] -or $src -is [System.Windows.Media.Media3D.Visual3D]) {
                [System.Windows.Media.VisualTreeHelper]::GetParent($src)
            }
            else { [System.Windows.LogicalTreeHelper]::GetParent($src) }
        }
        if (!$src) {
            Write-Debug "RowContextMenu: right-click resolved no DataGridRow (empty grid space) - no target stashed."
            return
        }

        $clickState.Item = $src.Item
        Write-Debug "RowContextMenu: right-click target = row item '$($src.Item)'."
        if (!$sender.SelectedItems.Contains($src.Item)) {
            # SelectedItems.Clear() throws on Single mode grids ("Can only change SelectedItems collection in multiple selection modes") even when it's empty - clearing nothing counts as a change, apparently. And this handler runs on every rightclick. SelectedItem assignment replaces the selection in Single mode by itself.
            if ($sender.SelectionMode -ne [System.Windows.Controls.DataGridSelectionMode]::Single) { $sender.SelectedItems.Clear() }
            $sender.SelectedItem = $src.Item
        }
    }.GetNewClosure())

    # Per row Enabled eval. Throws are eaten and treated as disabled. Write-Error inside a callback has nowhere to go. Both the enablement pass when the menu opens and the click dispatch close over this. $null means "no Enabled given". A literal $false has to stay $false, so no truth shortcut here.
    $testEnabled = {
        param($EnabledRef, $Row)
        if ($null -eq $EnabledRef) { return $true }
        try {
            if ($EnabledRef -is [scriptblock]) {
                $vars = [System.Collections.Generic.List[psvariable]]::new()
                $vars.Add([psvariable]::new('_', $Row))
                $vars.Add([psvariable]::new('row', $Row))
                $result = $EnabledRef.InvokeWithContext($null, $vars, $Row)
                return [bool](@($result) | Select-Object -Last 1)
            }
            return [bool]$EnabledRef
        }
        catch {
            Write-Debug "RowContextMenu Enabled check failed: $_"
            return $false
        }
    }

    foreach ($label in $Items.Keys) {
        $itemDef    = $Items[$label]
        $actionRef  = $null
        $enabledRef = $null
        $iconName   = $null
        $syncRef    = $false

        if ($itemDef -is [scriptblock]) { $actionRef = $itemDef }
        elseif ($itemDef -is [System.Collections.IDictionary]) {
            $actionRef  = $itemDef['Action']
            $enabledRef = $itemDef['Enabled']
            $iconName   = [string]$itemDef['Icon']
            if ($itemDef.Contains('Sync')) { $syncRef = [bool]$itemDef['Sync'] }
        }

        if (!$actionRef) {
            Write-Warning "RowContextMenu: '$label' has no Action; skipping."
            continue
        }

        $menuItem = [System.Windows.Controls.MenuItem]::new()
        $menuItem.Header = [string]$label

        if ($iconName) {
            $glyph = [PsUi.ModuleContext]::GetIcon($iconName)
            if ($glyph) {
                $iconTb = [System.Windows.Controls.TextBlock]@{
                    Text       = $glyph
                    FontFamily = [PsUi.ModuleContext]::ActiveIconFontFamily
                    FontSize   = 14
                }
                $menuItem.Icon = $iconTb
            }
        }

        $capturedAction  = $actionRef
        $capturedEnabled = $enabledRef
        $capturedSync    = $syncRef
        $gridRef         = $DataGrid

        $capturedLabel = [string]$label

        $menuItem.Add_Click({
            # ContextMenuOpening stashed the row under the rightclick. SelectedItem covers menus opened from the keyboard (Shift+F10) where no mouse walk happened.
            $clicked = $clickState.Item
            if ($null -eq $clicked) { $clicked = $gridRef.SelectedItem }
            if ($null -eq $clicked) {
                Write-Debug "RowContextMenu '$capturedLabel': no target (clickState and SelectedItem both null) - nothing to act on."
                return
            }

            # If the rightclick landed inside a multi selection, fan the action across every selected row (Excel / Explorer convention). Otherwise act on the click target only.
            # Outer @() is load bearing: when the if yields a single element PS unwraps it to a scalar, and a scalar's .Count is $null under 5.1.
            $selected = @($gridRef.SelectedItems)
            $targets  = @(if ($selected.Count -gt 1 -and ($selected -contains $clicked)) { $selected } else { $clicked })

            # Reeval Enabled per row before dispatch. The menu enables when any row passes, so a mixed selection lands here with some rows ineligible. Skip them silently.
            $eligible = [System.Collections.Generic.List[object]]::new()
            foreach ($target in $targets) {
                if ($null -ne $capturedEnabled -and !(& $testEnabled $capturedEnabled $target)) { continue }
                [void]$eligible.Add($target)
            }
            if ($eligible.Count -eq 0) {
                Write-Debug "RowContextMenu '$capturedLabel': all $($targets.Count) target(s) failed the Enabled check - action skipped."
                return
            }
            Write-Debug "RowContextMenu '$capturedLabel': dispatching to $($eligible.Count) row(s)."

            # ONE Invoke-UiAction call for the whole batch: a single background runspace loops the rows, so Stop-UiAsync / the status bar's AutoCancel cancel all of them (a runspace per row left Cancel holding only the last one) and the action's AST is scanned once, not N times.
            # -FanOut skips the Remove+Insert container regen workaround. One Items.Refresh at the end covers the whole batch.
            $itemArg = if ($eligible.Count -eq 1) { $eligible[0] } else { $eligible }
            Invoke-UiAction -Action $capturedAction -Item $itemArg -RefreshTarget $gridRef -Sync:$capturedSync -FanOut:($eligible.Count -gt 1)
        }.GetNewClosure())

        # Literal bool is a constant - set it once and skip probing on every open. Scriptblocks reevaluate through Tag each time the menu opens (a static IsEnabled would freeze on the first row).
        if ($capturedEnabled -is [scriptblock]) {
            $menuItem.Tag = @{ EnabledRef = $capturedEnabled; GridRef = $gridRef }
        }
        elseif ($null -ne $capturedEnabled) {
            # Tag the static state too - without it the Opened pass below can't tell a deliberate $false from an item with no Enabled clause, and would happily reenable it.
            $menuItem.IsEnabled = [bool]$capturedEnabled
            $menuItem.Tag       = @{ StaticEnabled = [bool]$capturedEnabled }
        }

        $menu.Items.Insert($insertIndex, $menuItem)
        $insertIndex++
        $rowMenuItems.Add($menuItem)
    }

    if ($rowMenuItems.Count -eq 0) { return }

    $separator = [System.Windows.Controls.Separator]::new()
    $menu.Items.Insert($insertIndex, $separator)
    $rowMenuItems.Add($separator)

    $itemsRef  = $rowMenuItems
    $gridForRef = $DataGrid
    $menu.Add_Opened({
        param($sender, $eventArgs)

        # No row under the click and nothing selected (rightclick on empty grid space): the row actions have nothing to act on, so gray them all out rather than let a click die silently. Matches Explorer - rightclick the void, the item commands are dead.
        $target = $clickState.Item
        if ($null -eq $target) { $target = $gridForRef.SelectedItem }
        if ($null -eq $target) {
            Write-Debug "RowContextMenu: opened over no target - disabling all row items."
            foreach ($mi in $itemsRef) {
                if ($mi -is [System.Windows.Controls.MenuItem]) { $mi.IsEnabled = $false }
            }
            return
        }

        foreach ($mi in $itemsRef) {

            if ($mi -isnot [System.Windows.Controls.MenuItem]) { continue }
            $tag = $mi.Tag
            
            # A literal Enabled item carries StaticEnabled - restore exactly that, so a deliberate $false stays grayed after a no target open.
            if ($tag -and $tag.ContainsKey('StaticEnabled')) { $mi.IsEnabled = $tag.StaticEnabled; continue }
            
            # Reenable items with no enabled clause... an earlier open over empty space leaves them grayed.
            if (!$tag -or !$tag.EnabledRef) { $mi.IsEnabled = $true; continue }

            # Mirror the click handler's targetting. The item enables when any target passes. The click handler rechecks each row anyway.
            # Probe cap at 20 rows. the loop dies on the first pass, but 10k selected rows against a slow enabled would freeze the menu open and that would suck.
            # Beyond the cap, enable optimisticaly. The click handler still filters. ContextMenuOpening already ran (it fires before Opened), so $clickState is fresh.
            $clicked = $clickState.Item
            if ($null -eq $clicked) { $clicked = $tag.GridRef.SelectedItem }
            
            # Same @() as the click handler. A single element unwraps to a scalar whose .Count is $null under 5.1, which zeroed probeMax and skipped the probe (menu item stuck disabled). Keep it an array.
            $selected = @($tag.GridRef.SelectedItems)
            $targets  = @(if ($selected.Count -gt 1 -and ($selected -contains $clicked)) { $selected } else { $clicked })

            $probeMax = [Math]::Min(20, $targets.Count)
            $enabled  = $targets.Count -gt $probeMax
            
            for ($i = 0; $i -lt $probeMax -and !$enabled; $i++) {
                if (& $testEnabled $tag.EnabledRef $targets[$i]) { $enabled = $true }
            }
            $mi.IsEnabled = $enabled
        }
    }.GetNewClosure())
}