PropertyBox/SRxPropertyBoxControl.psm1.txt

<#
================================================================================
  SRxPropertyBoxControl.psm1 (STEP 3 - fully implemented)
  -----------------------------------------------------------------------------
  Self-contained WinForms UserControl encapsulating the entire "Term custom
  properties" subsystem, decoupled from the Explorer host.
 
  Host uses it by COMPOSITION + DYNAMIC DISPATCH (untyped field). WinForms/Drawing
  must be loaded before this module is imported. Create via New-SRxPropertyBoxControl.
 
  Host contract:
    Properties : SourcePath, Icons(@{Save;Stop;Trash;Close;Busy})
    Callbacks : OnNavigate($token), OnClose(), OnStatus($text)
    Methods : BindContext($ctx), HasUnsavedChanges(), RequestNavigate($ctx),
                 RequestClose(), ApplyIcons(), Shutdown()
    Exposes : GlobalManagerPanel()
 
  A $ctx is @{ TermId; DisplayName; Tag(byref to node.Tag); Token(the node) }.
  Node-scoped state persists on $ctx.Tag (PropUpdate/EditBase/Draft/CustomProperties).
================================================================================
#>
# Bring the property-box logic + merge functions into THIS module's scope so the
# class methods (ConvertTo-SRxLogicalProps, Split-SRxChunkedProperties,
# Start/Get/Stop-SRxTermBatchOperation, Merge-SRxTermProperties, Show-SRxMergeDialog,
# Sync-SRxLocalTermBatch, Remove-SRxBatchTempFiles) can resolve them.
Import-Module (Join-Path $PSScriptRoot 'SRxPropertyBox.psm1') -DisableNameChecking -Force
Import-Module (Join-Path $PSScriptRoot 'SRxTermMerge.psm1') -DisableNameChecking -Force
 
class SRxPropertyBoxControl : System.Windows.Forms.UserControl {
 
    # ---- public config ----
    [string] $SourcePath = ''
    [hashtable] $Icons = @{}
 
    # ---- public callbacks ----
    [scriptblock] $OnNavigate = $null
    [scriptblock] $OnClose = $null
    [scriptblock] $OnStatus = $null
 
    # ---- internal UI ----
    hidden [System.Windows.Forms.ToolStrip] $_title = $null
    hidden [System.Windows.Forms.ToolStripLabel] $_titleLabel = $null
    hidden [System.Windows.Forms.ToolStripButton] $_btnSave = $null
    hidden [System.Windows.Forms.ToolStripButton] $_btnClose = $null
    hidden [System.Windows.Forms.DataGridView] $_dataGridView = $null
    hidden [System.Windows.Forms.DataGridViewImageColumn] $_colDelete = $null
    hidden [System.Windows.Forms.Panel] $_propOverlay = $null
    hidden [System.Windows.Forms.Label] $_propOverlayMsg = $null
    hidden [System.Windows.Forms.Button] $_propOverlaySave = $null
    hidden [System.Windows.Forms.Button] $_propOverlayDiscard = $null
    hidden [System.Windows.Forms.Button] $_propOverlayCancel = $null
 
    # ---- global manager bar (built here, docked by host) ----
    hidden [System.Windows.Forms.Panel] $_globalMgr = $null
    hidden [System.Windows.Forms.Label] $_globalMgrLabel = $null
    hidden [System.Windows.Forms.PictureBox] $_globalMgrKill = $null
    hidden [System.Windows.Forms.PictureBox] $_globalMgrBusy = $null
 
    # ---- engine ----
    hidden [System.Windows.Forms.Timer] $_batchTimer = $null
    hidden [System.Windows.Forms.Timer] $_spinTimer = $null
    hidden [System.Windows.Forms.Timer] $_dbReloadTimer = $null
    hidden [System.Collections.Hashtable] $_activeBatches = @{}
    hidden [System.Drawing.Font] $_strikeFont = $null
    hidden [boolean] $_suppressGuard = $false
    hidden [int] $_hoverDelRow = -1
 
    # ---- current context + pending overlay action ----
    hidden [object] $_currentCtx = $null
    hidden [object] $_pendingTargetCtx = $null
    hidden [string] $_pendingAction = ''
 
    SRxPropertyBoxControl() {
        $this.Dock = [System.Windows.Forms.DockStyle]::Fill
        $this.buildLayout()
        $this.buildGlobalManager()
        $this.buildTimers()
    }
 
    # ========================================================== layout
 
    hidden [void] buildLayout() {
        $this._title = [System.Windows.Forms.ToolStrip]::new()
        $this._title.Dock = [System.Windows.Forms.DockStyle]::Top
        $this._title.GripStyle = [System.Windows.Forms.ToolStripGripStyle]::Hidden
        $this._title.BackColor = [System.Drawing.SystemColors]::ControlLight
        $this._title.ImageScalingSize = [System.Drawing.Size]::new(20, 20)
 
        $this._btnSave = [System.Windows.Forms.ToolStripButton]::new()
        $this._btnSave.Name = '_btnSave'
        $this._btnSave.DisplayStyle = [System.Windows.Forms.ToolStripItemDisplayStyle]::Image
        $this._btnSave.Alignment = [System.Windows.Forms.ToolStripItemAlignment]::Left
        $this._btnSave.AutoSize = $false
        $this._btnSave.Size = [System.Drawing.Size]::new(24, 24)
        $this._btnSave.ImageScaling = [System.Windows.Forms.ToolStripItemImageScaling]::SizeToFit
        $this._btnSave.AutoToolTip = $false
        $this._btnSave.ToolTipText = ''
        $this._btnSave.Enabled = $false
        $this._btnSave.Visible = $false
        $this._btnSave.Tag = $this
        $this._btnSave.add_Click({ param($s,$e) $s.Tag.onSaveButtonClick() })
        $this._btnSave.add_MouseEnter({ param($s,$e) $s.Tag.onSaveButtonMouseEnter() })
        $this._btnSave.add_MouseLeave({ param($s,$e) $s.Tag.onSaveButtonMouseLeave() })
 
        $this._titleLabel = [System.Windows.Forms.ToolStripLabel]::new('Term custom properties')
 
        $this._btnClose = [System.Windows.Forms.ToolStripButton]::new()
        $this._btnClose.Alignment = [System.Windows.Forms.ToolStripItemAlignment]::Right
        $this._btnClose.DisplayStyle = [System.Windows.Forms.ToolStripItemDisplayStyle]::Image
        $this._btnClose.AutoSize = $false
        $this._btnClose.Size = [System.Drawing.Size]::new(24, 24)
        $this._btnClose.ImageScaling = [System.Windows.Forms.ToolStripItemImageScaling]::SizeToFit
        $this._btnClose.AutoToolTip = $false
        $this._btnClose.Tag = $this
        $this._btnClose.add_Click({ param($s,$e) $s.Tag.RequestClose() })
 
        [void]$this._title.Items.Add($this._btnSave)
        [void]$this._title.Items.Add($this._titleLabel)
        [void]$this._title.Items.Add($this._btnClose)
 
        $this._dataGridView = [System.Windows.Forms.DataGridView]::new()
        $this._dataGridView.Dock = [System.Windows.Forms.DockStyle]::Fill
        $this._dataGridView.Name = 'dataGridView'
        $this._dataGridView.BorderStyle = 'None'
        $this._dataGridView.ColumnHeadersVisible = $false
        $this._dataGridView.RowHeadersVisible = $false
        $this._dataGridView.EditMode = [System.Windows.Forms.DataGridViewEditMode]::EditOnKeystrokeOrF2
        $this._dataGridView.AllowUserToAddRows = $true
 
        $this._colDelete = New-Object System.Windows.Forms.DataGridViewImageColumn
        $this._colDelete.Name = 'Del'
        $this._colDelete.HeaderText = ''
        $this._colDelete.Width = 24
        $this._colDelete.ImageLayout = [System.Windows.Forms.DataGridViewImageCellLayout]::Zoom
        $this._colDelete.DefaultCellStyle.NullValue = $null
        $this._colDelete.DefaultCellStyle.BackColor = [System.Drawing.Color]::White
        $this._colDelete.DefaultCellStyle.SelectionBackColor = [System.Drawing.Color]::White
        $this._colDelete.Resizable = [System.Windows.Forms.DataGridViewTriState]::False
        [void]$this._dataGridView.Columns.Add($this._colDelete)
 
        $colKey = [System.Windows.Forms.DataGridViewTextBoxColumn]::new()
        $colKey.Name = 'Key'; $colKey.HeaderText = 'Attribute'; $colKey.Width = 100
        [void]$this._dataGridView.Columns.Add($colKey)
 
        $colVal = [System.Windows.Forms.DataGridViewTextBoxColumn]::new()
        $colVal.Name = 'Value'; $colVal.HeaderText = 'Value'
        $colVal.AutoSizeMode = [System.Windows.Forms.DataGridViewAutoSizeColumnMode]::Fill
        [void]$this._dataGridView.Columns.Add($colVal)
 
        $this._strikeFont = [System.Drawing.Font]::new($this._dataGridView.Font, [System.Drawing.FontStyle]::Strikeout)
 
        $this._dataGridView.Tag = $this
        $this._dataGridView.add_CellContentClick({ param($s,$e) $s.Tag.onGridDeleteClick($e.RowIndex, $e.ColumnIndex) })
        $this._dataGridView.add_CellValueChanged({ param($s,$e) $s.Tag.onGridValueChanged($e.RowIndex, $e.ColumnIndex) })
        $this._dataGridView.add_CellFormatting({ param($s,$e) $s.Tag.onGridFormatting($e) })
        $this._dataGridView.add_CellBeginEdit({ param($s,$e) $s.Tag.onGridBeginEdit($e) })
        $this._dataGridView.add_KeyDown({ param($s,$e) $s.Tag.onGridKeyDown($e) })
        $this._dataGridView.add_CellMouseEnter({ param($s,$e) $s.Tag.onGridCellMouseEnter($e.RowIndex, $e.ColumnIndex) })
        $this._dataGridView.add_CellMouseLeave({ param($s,$e) $s.Tag.onGridCellMouseLeave($e.RowIndex, $e.ColumnIndex) })
        $this._dataGridView.add_CurrentCellDirtyStateChanged({
            param($s,$e)
            if ($s.IsCurrentCellDirty) { $s.CommitEdit([System.Windows.Forms.DataGridViewDataErrorContexts]::Commit) }
        })
 
        # overlay (child of grid, Dock=Fill => covers grid, auto-resizes)
        $this._propOverlay = [System.Windows.Forms.Panel]::new()
        $this._propOverlay.BackColor = [System.Drawing.Color]::FromArgb(250, 250, 250)
        $this._propOverlay.BorderStyle = [System.Windows.Forms.BorderStyle]::FixedSingle
        $this._propOverlay.Visible = $false
        $this._propOverlay.Dock = [System.Windows.Forms.DockStyle]::Fill
 
        $this._propOverlayMsg = [System.Windows.Forms.Label]::new()
        $this._propOverlayMsg.AutoSize = $false
        $this._propOverlayMsg.Font = [System.Drawing.Font]::new('Segoe UI', 9)
        $this._propOverlayMsg.ForeColor = [System.Drawing.Color]::FromArgb(30, 30, 30)
        $this._propOverlayMsg.Location = [System.Drawing.Point]::new(16, 16)
        $this._propOverlayMsg.Size = [System.Drawing.Size]::new(360, 48)
        $this._propOverlayMsg.Text = 'You have unsaved property changes.'
        [void]$this._propOverlay.Controls.Add($this._propOverlayMsg)
 
        $this._propOverlaySave = [System.Windows.Forms.Button]::new()
        $this._propOverlaySave.Text = 'Save changes'
        $this._propOverlaySave.Font = [System.Drawing.Font]::new('Segoe UI', 9)
        $this._propOverlaySave.Size = [System.Drawing.Size]::new(160, 30)
        $this._propOverlaySave.Location = [System.Drawing.Point]::new(16, 76)
        $this._propOverlaySave.FlatStyle = [System.Windows.Forms.FlatStyle]::System
        $this._propOverlaySave.TextAlign = [System.Drawing.ContentAlignment]::MiddleLeft
        $this._propOverlaySave.ImageAlign = [System.Drawing.ContentAlignment]::MiddleLeft
        $this._propOverlaySave.TextImageRelation = [System.Windows.Forms.TextImageRelation]::ImageBeforeText
        $this._propOverlaySave.Padding = [System.Windows.Forms.Padding]::new(6, 0, 0, 0)
        $this._propOverlaySave.Tag = $this
        $this._propOverlaySave.add_Click({ param($s,$e) $s.Tag.onOverlaySave() })
        [void]$this._propOverlay.Controls.Add($this._propOverlaySave)
 
        $this._propOverlayDiscard = [System.Windows.Forms.Button]::new()
        $this._propOverlayDiscard.Text = 'Discard changes'
        $this._propOverlayDiscard.Font = [System.Drawing.Font]::new('Segoe UI', 9)
        $this._propOverlayDiscard.Size = [System.Drawing.Size]::new(160, 30)
        $this._propOverlayDiscard.Location = [System.Drawing.Point]::new(16, 112)
        $this._propOverlayDiscard.FlatStyle = [System.Windows.Forms.FlatStyle]::System
        $this._propOverlayDiscard.TextAlign = [System.Drawing.ContentAlignment]::MiddleLeft
        $this._propOverlayDiscard.Tag = $this
        $this._propOverlayDiscard.add_Click({ param($s,$e) $s.Tag.onOverlayDiscard() })
        [void]$this._propOverlay.Controls.Add($this._propOverlayDiscard)
 
        $this._propOverlayCancel = [System.Windows.Forms.Button]::new()
        $this._propOverlayCancel.Text = 'Cancel'
        $this._propOverlayCancel.Font = [System.Drawing.Font]::new('Segoe UI', 9)
        $this._propOverlayCancel.Size = [System.Drawing.Size]::new(160, 30)
        $this._propOverlayCancel.Location = [System.Drawing.Point]::new(16, 148)
        $this._propOverlayCancel.FlatStyle = [System.Windows.Forms.FlatStyle]::System
        $this._propOverlayCancel.TextAlign = [System.Drawing.ContentAlignment]::MiddleLeft
        $this._propOverlayCancel.Tag = $this
        $this._propOverlayCancel.add_Click({ param($s,$e) $s.Tag.onOverlayCancel() })
        [void]$this._propOverlay.Controls.Add($this._propOverlayCancel)
 
        [void]$this._dataGridView.Controls.Add($this._propOverlay)
 
        [void]$this.Controls.Add($this._dataGridView)
        [void]$this.Controls.Add($this._title)
    }
 
    hidden [void] buildGlobalManager() {
        $this._globalMgr = [System.Windows.Forms.Panel]::new()
        $this._globalMgr.Height = 30
        $this._globalMgr.Dock = [System.Windows.Forms.DockStyle]::Top
        $this._globalMgr.BackColor = [System.Drawing.Color]::White
        $this._globalMgr.Visible = $false
 
        $this._globalMgrKill = [System.Windows.Forms.PictureBox]::new()
        $this._globalMgrKill.SizeMode = [System.Windows.Forms.PictureBoxSizeMode]::Zoom
        $this._globalMgrKill.Size = [System.Drawing.Size]::new(20, 20)
        $this._globalMgrKill.Location = [System.Drawing.Point]::new(8, 5)
        $this._globalMgrKill.BackColor = [System.Drawing.Color]::Transparent
        $this._globalMgrKill.Cursor = [System.Windows.Forms.Cursors]::Hand
        $this._globalMgrKill.Tag = $this
        $this._globalMgrKill.add_Click({ param($s,$e) $s.Tag.killAllBatches() })
        [void]$this._globalMgr.Controls.Add($this._globalMgrKill)
 
        $this._globalMgrBusy = [System.Windows.Forms.PictureBox]::new()
        $this._globalMgrBusy.SizeMode = [System.Windows.Forms.PictureBoxSizeMode]::Zoom
        $this._globalMgrBusy.Size = [System.Drawing.Size]::new(20, 20)
        $this._globalMgrBusy.Location = [System.Drawing.Point]::new(36, 5)
        $this._globalMgrBusy.BackColor = [System.Drawing.Color]::Transparent
        $this._globalMgrBusy.Visible = $false
        [void]$this._globalMgr.Controls.Add($this._globalMgrBusy)
 
        $this._globalMgrLabel = [System.Windows.Forms.Label]::new()
        $this._globalMgrLabel.AutoSize = $true
        $this._globalMgrLabel.Font = [System.Drawing.Font]::new('Segoe UI', 9)
        $this._globalMgrLabel.ForeColor = [System.Drawing.Color]::FromArgb(30, 30, 30)
        $this._globalMgrLabel.Location = [System.Drawing.Point]::new(64, 7)
        $this._globalMgrLabel.Text = 'Properties update in progress'
        [void]$this._globalMgr.Controls.Add($this._globalMgrLabel)
    }
 
    hidden [void] buildTimers() {
        $this._batchTimer = [System.Windows.Forms.Timer]::new()
        $this._batchTimer.Interval = 500
        $this._batchTimer.Tag = $this
        $this._batchTimer.add_tick({ param($s,$e) $s.Tag.pollBatches() })
 
        $this._spinTimer = [System.Windows.Forms.Timer]::new()
        $this._spinTimer.Interval = 120
        $this._spinTimer.Tag = $this
        $this._spinTimer.add_tick({ param($s,$e) $s.Tag.spinGlobalManager() })
 
        $this._dbReloadTimer = [System.Windows.Forms.Timer]::new()
        $this._dbReloadTimer.Interval = 750
        $this._dbReloadTimer.Tag = $this
        $this._dbReloadTimer.add_tick({ param($s,$e) $s.Tag.onDbReloadTick() })
    }
 
    # ========================================================== public API
 
    [System.Windows.Forms.Panel] GlobalManagerPanel() { return $this._globalMgr }
 
    [void] ApplyIcons() {
        try {
            if ($this.Icons.ContainsKey('Save') -and $this.Icons['Save']) { $this._btnSave.Image = $this.Icons['Save'] }
            if ($this.Icons.ContainsKey('Close') -and $this.Icons['Close']) { $this._btnClose.Image = $this.Icons['Close']; $this._globalMgrKill.Image = $this.Icons['Close'] }
            if ($this.Icons.ContainsKey('Busy') -and $this.Icons['Busy']) { $this._globalMgrBusy.Image = $this.Icons['Busy'] }
            $this.updateSaveButton()
        } catch { Write-Host "ApplyIcons - $($_.Exception.Message)" }
    }
 
    [void] BindContext($ctx) {
        $this._currentCtx = $ctx
        $this.bindNodeToBox($ctx)
    }
 
    [bool] HasUnsavedChanges() { return $this.nodeHasChanges() }
 
    [bool] RequestNavigate($targetCtx) {
        try {
            if (-not $this.nodeHasChanges()) { return $true }
            $this._pendingTargetCtx = $targetCtx
            $this._pendingAction = 'switch'
            $this.showUnsavedOverlay()
            return $false
        } catch { Write-Host "RequestNavigate - $($_.Exception.Message)"; return $true }
    }
 
    [void] RequestClose() {
        try {
            if ($this.nodeHasChanges()) {
                $this._pendingAction = 'close'
                $this.showUnsavedOverlay()
            } else {
                $this.doClose()
            }
        } catch { Write-Host "RequestClose - $($_.Exception.Message)" }
    }
 
    [void] Shutdown() {
        try {
            if ($this._batchTimer) { $this._batchTimer.Stop() }
            if ($this._spinTimer) { $this._spinTimer.Stop() }
            if ($this._dbReloadTimer) { $this._dbReloadTimer.Stop() }
            foreach ($entry in @($this._activeBatches.GetEnumerator())) {
                try { Stop-SRxTermBatchOperation -Operation $entry.Value.Op | Out-Null } catch {}
            }
            $this._activeBatches.Clear()
        } catch { Write-Host "Shutdown - $($_.Exception.Message)" }
    }
 
    # ========================================================== callback helpers
 
    hidden [void] setStatus([string]$text) {
        try { if ($this.OnStatus) { & $this.OnStatus $text } } catch { }
    }
    hidden [void] doClose() {
        try { if ($this.OnClose) { & $this.OnClose } } catch { }
    }
 
    # ========================================================== grid model
 
    hidden [void] bindNodeToBox($ctx) {
        try {
            $this._suppressGuard = $true
            [void]$this._dataGridView.Rows.Clear()
            if ($null -ne $ctx) {
                if ($ctx.Tag.PSObject.Properties.Name -contains 'Draft' -and $ctx.Tag.Draft) {
                    $this.loadDraftRows($ctx.Tag.Draft)
                } else {
                    $props = $ctx.Tag.CustomProperties
                    if ($null -ne $props) {
                        $logical = ConvertTo-SRxLogicalProps -Raw ([hashtable]$this.toHashtable($props))
                        foreach ($k in ($logical.Keys | Sort-Object)) {
                            $idx = $this._dataGridView.Rows.Add()
                            $row = $this._dataGridView.Rows[$idx]
                            $row.Cells['Key'].Value = $k
                            $row.Cells['Value'].Value = $logical[$k]
                            $row.Tag = [pscustomobject]@{ Key=$k; Original=[string]$logical[$k]; IsNew=$false; DeleteIntent=$false }
                            $row.Cells['Key'].ReadOnly = $true
                        }
                    }
                }
            }
            $this._dataGridView.ClearSelection()
            $this.updateSaveButton()
        } catch { Write-Host "bindNodeToBox - $($_.Exception.Message)" }
        finally { $this._suppressGuard = $false }
    }
 
    hidden [hashtable] toHashtable($obj) {
        $h = @{}
        if ($obj -is [hashtable]) { foreach ($k in $obj.Keys) { $h[$k] = $obj[$k] } }
        elseif ($null -ne $obj) { foreach ($p in $obj.PSObject.Properties) { $h[$p.Name] = $p.Value } }
        return $h
    }
 
    hidden [void] loadDraftRows($draft) {
        $this._suppressGuard = $true
        foreach ($r in $draft.Rows) {
            $idx = $this._dataGridView.Rows.Add()
            $row = $this._dataGridView.Rows[$idx]
            $row.Cells['Key'].Value = $r.Key
            $row.Cells['Value'].Value = $r.Value
            $row.Tag = [pscustomobject]@{ Key=$r.Key; Original=$r.Original; IsNew=$r.IsNew; DeleteIntent=$r.DeleteIntent }
            $row.Cells['Key'].ReadOnly = (-not $r.IsNew)
        }
        $this._suppressGuard = $false
    }
 
    hidden [boolean] nodeHasChanges() {
        foreach ($gr in $this._dataGridView.Rows) {
            if ($gr.IsNewRow) { continue }
            if (-not $gr.Tag) { continue }
            if ($gr.Tag.IsNew) { return $true }
            if ($gr.Tag.DeleteIntent) { return $true }
            if ([string]$gr.Cells['Value'].Value -ne [string]$gr.Tag.Original) { return $true }
        }
        return $false
    }
 
    hidden [hashtable] buildDesiredSet() {
        $desired = @{}
        foreach ($gr in $this._dataGridView.Rows) {
            if ($gr.IsNewRow) { continue }
            if (-not $gr.Tag) { continue }
            if ($gr.Tag.DeleteIntent) { continue }
            $k = [string]$gr.Cells['Key'].Value
            if ([string]::IsNullOrWhiteSpace($k)) { continue }
            $desired[$k] = [string]$gr.Cells['Value'].Value
        }
        return $desired
    }
 
    hidden [hashtable] buildOriginalSet() {
        $orig = @{}
        foreach ($gr in $this._dataGridView.Rows) {
            if ($gr.IsNewRow) { continue }
            if (-not $gr.Tag -or $gr.Tag.IsNew) { continue }
            $orig[[string]$gr.Tag.Key] = [string]$gr.Tag.Original
        }
        return $orig
    }
 
    # ========================================================== grid handlers
 
    hidden [void] onGridBeginEdit($e) {
        try {
            if ($e.RowIndex -lt 0) { return }
            $row = $this._dataGridView.Rows[$e.RowIndex]
            if ($row.IsNewRow) { return }
            $col = $this._dataGridView.Columns[$e.ColumnIndex].Name
            if ($col -eq 'Del') { $e.Cancel = $true; return }
            if ($col -eq 'Key' -and $row.Tag -and -not $row.Tag.IsNew) { $e.Cancel = $true }
            if ($col -eq 'Value' -and $row.Tag -and $row.Tag.DeleteIntent) { $e.Cancel = $true }
        } catch { Write-Host "onGridBeginEdit - $($_.Exception.Message)" }
    }
 
    hidden [void] onGridValueChanged([int]$rowIndex, [int]$columnIndex) {
        try {
            if ($this._suppressGuard) { return }
            if ($rowIndex -lt 0) { return }
            $row = $this._dataGridView.Rows[$rowIndex]
            if ($row.IsNewRow) { return }
            if (-not $row.Tag) {
                $row.Tag = [pscustomobject]@{ Key=[string]$row.Cells['Key'].Value; Original=''; IsNew=$true; DeleteIntent=$false }
                $row.Cells['Key'].ReadOnly = $false
            }
            $this.captureEditBase($this._currentCtx)
            $this._dataGridView.InvalidateRow($rowIndex)
            $this.updateSaveButton()
        } catch { Write-Host "onGridValueChanged - $($_.Exception.Message)" }
    }
 
    hidden [void] onGridKeyDown($e) {
        try {
            if ($e.KeyCode -ne [System.Windows.Forms.Keys]::Escape) { return }
            $cell = $this._dataGridView.CurrentCell
            if ($null -eq $cell) { return }
            $row = $this._dataGridView.Rows[$cell.RowIndex]
            if ($row.IsNewRow -or -not $row.Tag) { return }
            $col = $this._dataGridView.Columns[$cell.ColumnIndex].Name
            if ($col -eq 'Value' -and -not $row.Tag.IsNew) {
                $this._dataGridView.CancelEdit()
                $row.Cells['Value'].Value = $row.Tag.Original
                $this._dataGridView.InvalidateRow($cell.RowIndex)
                $this.updateSaveButton()
                $e.Handled = $true
            }
        } catch { Write-Host "onGridKeyDown - $($_.Exception.Message)" }
    }
 
    hidden [void] onGridDeleteClick([int]$rowIndex, [int]$columnIndex) {
        try {
            if ($rowIndex -lt 0 -or $columnIndex -lt 0) { return }
            if ($this._dataGridView.Columns[$columnIndex].Name -ne 'Del') { return }
            $row = $this._dataGridView.Rows[$rowIndex]
            if ($row.IsNewRow -or -not $row.Tag) { return }
            if ($row.Tag.IsNew) {
                [void]$this._dataGridView.Rows.RemoveAt($rowIndex)
            } else {
                $row.Tag.DeleteIntent = -not $row.Tag.DeleteIntent
                $this.captureEditBase($this._currentCtx)
                $this._dataGridView.InvalidateRow($rowIndex)
            }
            $this.updateSaveButton()
        } catch { Write-Host "onGridDeleteClick - $($_.Exception.Message)" }
    }
 
    hidden [void] onGridCellMouseEnter([int]$rowIndex, [int]$columnIndex) {
        try {
            if ($rowIndex -lt 0 -or $columnIndex -lt 0) { return }
            if ($this._dataGridView.Columns[$columnIndex].Name -ne 'Del') { return }
            $row = $this._dataGridView.Rows[$rowIndex]
            if ($row.IsNewRow -or -not $row.Tag) { return }
            $cell = $row.Cells['Del']
            $cell.Style.BackColor = [System.Drawing.Color]::FromArgb(225, 235, 245)
            $this._dataGridView.Cursor = [System.Windows.Forms.Cursors]::Hand
            $this._hoverDelRow = $rowIndex
        } catch { Write-Host "onGridCellMouseEnter - $($_.Exception.Message)" }
    }
 
    hidden [void] onGridCellMouseLeave([int]$rowIndex, [int]$columnIndex) {
        try {
            if ($rowIndex -lt 0 -or $columnIndex -lt 0) { return }
            if ($this._dataGridView.Columns[$columnIndex].Name -ne 'Del') { return }
            $row = $this._dataGridView.Rows[$rowIndex]
            $cell = $row.Cells['Del']
            $cell.Style.BackColor = [System.Drawing.Color]::White
            $this._dataGridView.Cursor = [System.Windows.Forms.Cursors]::Default
            $this._hoverDelRow = -1
        } catch { Write-Host "onGridCellMouseLeave - $($_.Exception.Message)" }
    }
 
    hidden [void] onGridFormatting($e) {
        try {
            if ($e.RowIndex -lt 0) { return }
            $row = $this._dataGridView.Rows[$e.RowIndex]
            $col = $this._dataGridView.Columns[$e.ColumnIndex].Name
            if ($row.IsNewRow -or -not $row.Tag) {
                if ($col -eq 'Del') { $e.Value = $null }
                return
            }
            $t = $row.Tag
            if ($col -eq 'Del') {
                if ($this.Icons.ContainsKey('Trash') -and $this.Icons['Trash']) { $e.Value = $this.Icons['Trash'] } else { $e.Value = $null }
                return
            }
            if ($t.DeleteIntent) {
                $e.CellStyle.Font = $this._strikeFont
                $e.CellStyle.ForeColor = [System.Drawing.Color]::Gray
            }
            elseif ($t.IsNew) {
                $e.CellStyle.ForeColor = [System.Drawing.Color]::Red
            }
            elseif ($col -eq 'Value') {
                if ([string]$row.Cells['Value'].Value -ne [string]$t.Original) { $e.CellStyle.ForeColor = [System.Drawing.Color]::Red }
                else { $e.CellStyle.ForeColor = [System.Drawing.Color]::Black }
            }
        } catch { Write-Host "onGridFormatting - $($_.Exception.Message)" }
    }
 
    # ========================================================== save button
 
    hidden [void] updateSaveButton() {
        try {
            $ctx = $this._currentCtx
            $hasChanges = $this.nodeHasChanges()
            $running = ($null -ne $ctx -and ($ctx.Tag.PSObject.Properties.Name -contains 'PropUpdate') -and $ctx.Tag.PropUpdate)
            $this._btnSave.Text = ''
            $this._btnSave.AutoToolTip = $false
            $this._btnSave.ToolTipText = ''
            if ($hasChanges) {
                $this._btnSave.Visible = $true
                $this._btnSave.Enabled = $true
                if ($this.Icons.ContainsKey('Save') -and $this.Icons['Save']) { $this._btnSave.Image = $this.Icons['Save'] }
            }
            elseif ($running) {
                $this._btnSave.Visible = $true
                $this._btnSave.Enabled = $true
                if ($this.Icons.ContainsKey('Stop') -and $this.Icons['Stop']) { $this._btnSave.Image = $this.Icons['Stop'] }
                elseif ($this.Icons.ContainsKey('Save') -and $this.Icons['Save']) { $this._btnSave.Image = $this.Icons['Save'] }
            }
            else {
                $this._btnSave.Visible = $false
                $this._btnSave.Enabled = $false
            }
        } catch { Write-Host "updateSaveButton - $($_.Exception.Message)" }
    }
 
    hidden [void] onSaveButtonMouseEnter() {
        try {
            if (-not $this._btnSave.Enabled) { return }
            $this._btnSave.BackColor = [System.Drawing.Color]::FromArgb(225, 235, 245)
            if ($null -ne $this._title) { $this._title.Cursor = [System.Windows.Forms.Cursors]::Hand }
        } catch { Write-Host "onSaveButtonMouseEnter - $($_.Exception.Message)" }
    }
 
    hidden [void] onSaveButtonMouseLeave() {
        try {
            $this._btnSave.BackColor = [System.Drawing.Color]::Transparent
            if ($null -ne $this._title) { $this._title.Cursor = [System.Windows.Forms.Cursors]::Default }
        } catch { Write-Host "onSaveButtonMouseLeave - $($_.Exception.Message)" }
    }
 
    hidden [void] onSaveButtonClick() {
        try {
            $ctx = $this._currentCtx
            if ($null -eq $ctx) { return }
            if ($this.nodeHasChanges()) { $this.startBatchForNode($ctx) }
            elseif (($ctx.Tag.PSObject.Properties.Name -contains 'PropUpdate') -and $ctx.Tag.PropUpdate) {
                $this.killNodeBatch($ctx)
            }
        } catch { Write-Host "onSaveButtonClick - $($_.Exception.Message)" }
    }
 
    # ========================================================== batch engine
 
    hidden [void] startBatchForNode($ctx) {
        try {
            $termId = [string]$ctx.TermId
            if ([string]::IsNullOrEmpty($termId)) { $termId = [string]$ctx.Tag.termID }
 
            $desired = $this.buildDesiredSet()
            $original = $this.buildOriginalSet()
 
            $op = Start-SRxTermBatchOperation -TermId $termId -DesiredLogical $desired `
                    -OriginalLogical $original -BaselineModified '' `
                    -SourcePath $this.SourcePath -HideWindow
            if ($op.StartError) { $this.setStatus("Batch failed to start: $($op.StartError)"); return }
 
            $ctx.Tag | Add-Member -NotePropertyName PropUpdate -NotePropertyValue $op -Force
            $this._activeBatches[[string]$op.ProcessId] = @{ Ctx = $ctx; Op = $op }
 
            $ctx.Tag.CustomProperties = (Split-SRxChunkedProperties -Logical $desired)
            if ($ctx.Tag.PSObject.Properties.Name -contains 'Draft') { $ctx.Tag.Draft = $null }
            $this.bindNodeToBox($ctx)
 
            $this.setStatus("Batch started for '$($ctx.DisplayName)' (PID $($op.ProcessId)).")
            if (-not $this._batchTimer.Enabled) { $this._batchTimer.Start() }
            $this.updateGlobalManager()
            $this.updateSaveButton()
        } catch { Write-Host "startBatchForNode - $($_.Exception.Message)" }
    }
 
    hidden [void] pollBatches() {
        try {
            if ($this._activeBatches.Count -eq 0) { $this._batchTimer.Stop(); $this.updateGlobalManager(); return }
            $done = @()
            $finishedTermIds = @()
            foreach ($entry in @($this._activeBatches.GetEnumerator())) {
                $key = $entry.Key
                $ctx = $entry.Value.Ctx
                $op = $entry.Value.Op
                $st = Get-SRxTermBatchOperationState -Operation $op
                if ($st.State -eq 'Running') { continue }
 
                if ($st.State -eq 'Completed' -and $st.Success) {
                    if ($st.Data.PropsAfter) {
                        $ctx.Tag.CustomProperties = $this.toHashtable($st.Data.PropsAfter)
                    } elseif ($st.Data.PropsAfterRaw) {
                        $ctx.Tag.CustomProperties = $this.toHashtable((ConvertTo-SRxLogicalProps -Raw ([hashtable]$this.toHashtable($st.Data.PropsAfterRaw))))
                    }
                    if ($st.Data.OuterXml) { $ctx.Tag.OuterXml = $st.Data.OuterXml }
                    if ($ctx.Tag.PSObject.Properties.Name -contains 'EditBase') { $ctx.Tag.EditBase = $null }
                    if ($st.Data.SyncWarning) {
                        $this.setStatus("Saved '$($ctx.DisplayName)' (note: $($st.Data.SyncWarning))")
                    } else {
                        $this.setStatus("Saved '$($ctx.DisplayName)'.")
                    }
                    $this.scheduleDbReload()
                }
                elseif ($st.State -eq 'Conflict') {
                    $this.handleConflict($ctx, $op, $st)
                }
                else {
                    $this.setStatus("Batch failed for '$($ctx.DisplayName)': $($st.Error)")
                }
 
                Remove-SRxBatchTempFiles -Operation $op
                if (($ctx.Tag.PSObject.Properties.Name -contains 'PropUpdate') -and $ctx.Tag.PropUpdate -and `
                    ([string]$ctx.Tag.PropUpdate.ProcessId -eq [string]$op.ProcessId)) {
                    $ctx.Tag.PropUpdate = $null
                }
                $done += $key
                $finishedTermIds += [string]$ctx.TermId
            }
            foreach ($k in $done) { $this._activeBatches.Remove($k) }
 
            $sel = $this._currentCtx
            if ($null -ne $sel -and ($finishedTermIds -contains [string]$sel.TermId)) {
                if (-not $this._dataGridView.IsCurrentCellInEditMode) {
                    $this.bindNodeToBox($sel)
                } else {
                    $this.updateSaveButton()
                }
            } else {
                $this.updateSaveButton()
            }
 
            if ($this._activeBatches.Count -eq 0) {
                $this._batchTimer.Stop()
                $this.setStatus('Ready')
            }
            $this.updateGlobalManager()
        } catch { Write-Host "pollBatches - $($_.Exception.Message)" }
    }
 
    hidden [void] scheduleDbReload() {
        try {
            if ($null -eq $this._dbReloadTimer) { return }
            $this._dbReloadTimer.Stop()
            $this._dbReloadTimer.Start()
        } catch { Write-Host "scheduleDbReload - $($_.Exception.Message)" }
    }
 
    hidden [void] onDbReloadTick() {
        try {
            $this._dbReloadTimer.Stop()
            if ($this._activeBatches.Count -gt 0 -or $this._dataGridView.IsCurrentCellInEditMode) {
                $this._dbReloadTimer.Start()
                return
            }
            if (Get-Command Connect-SRxProvisioningDatabase_JSON -ErrorAction SilentlyContinue) {
                Connect-SRxProvisioningDatabase_JSON | Out-Null
            }
        } catch { Write-Host "onDbReloadTick - $($_.Exception.Message)" }
    }
 
    hidden [void] captureEditBase($ctx) {
        try {
            if ($null -eq $ctx) { return }
            if ($ctx.Tag.PSObject.Properties.Name -contains 'EditBase' -and $ctx.Tag.EditBase) { return }
            $baseLogical = @{}
            $props = $ctx.Tag.CustomProperties
            if ($null -ne $props) {
                $baseLogical = ConvertTo-SRxLogicalProps -Raw ([hashtable]$this.toHashtable($props))
            }
            $ctx.Tag | Add-Member -NotePropertyName EditBase -NotePropertyValue $baseLogical -Force
        } catch { Write-Host "captureEditBase - $($_.Exception.Message)" }
    }
 
    hidden [void] handleConflict($ctx, $op, $st) {
        try {
            $base = @{}
            if ($ctx.Tag.PSObject.Properties.Name -contains 'EditBase' -and $ctx.Tag.EditBase) {
                $base = [hashtable]$this.toHashtable($ctx.Tag.EditBase)
            }
            $mine = @{}
            if ($op.DesiredLogical) { $mine = [hashtable]$this.toHashtable($op.DesiredLogical) }
            $theirs = @{}
            if ($st.Data -and $st.Data.RemoteProps) { $theirs = [hashtable]$this.toHashtable($st.Data.RemoteProps) }
 
            $remoteModified = if ($st.Data -and $st.Data.RemoteModified) { [string]$st.Data.RemoteModified } else { '' }
            $baselineModified = if ($ctx.Tag.PSObject.Properties.Name -contains 'BaselineModified' -and $ctx.Tag.BaselineModified) { [string]$ctx.Tag.BaselineModified } else { '' }
 
            $merge = Merge-SRxTermProperties -Base $base -Mine $mine -Theirs $theirs
 
            $mergedSet = $null
            if ($merge.HasConflicts) {
                $mergedSet = Show-SRxMergeDialog -MergeResult $merge -TermName ([string]$ctx.DisplayName) `
                                -RemoteModified $remoteModified -BaselineModified $baselineModified
                if ($null -eq $mergedSet) {
                    $this.setStatus("Merge cancelled for '$($ctx.DisplayName)'; local changes kept, nothing written.")
                    return
                }
            } else {
                $mergedSet = $merge.Merged
                $this.setStatus("Auto-merged non-conflicting changes for '$($ctx.DisplayName)'.")
            }
 
            $termId = [string]$op.TermId
            $forceOp = Start-SRxTermBatchOperation -TermId $termId -DesiredLogical $mergedSet `
                         -OriginalLogical @{} -BaselineModified '' -SourcePath $this.SourcePath -Force -HideWindow
            if ($forceOp.StartError) {
                $this.setStatus("Merged commit failed to start: $($forceOp.StartError)")
                return
            }
 
            $ctx.Tag | Add-Member -NotePropertyName PropUpdate -NotePropertyValue $forceOp -Force
            $this._activeBatches[[string]$forceOp.ProcessId] = @{ Ctx = $ctx; Op = $forceOp }
 
            $ctx.Tag.CustomProperties = (Split-SRxChunkedProperties -Logical $mergedSet)
            if ($ctx.Tag.PSObject.Properties.Name -contains 'EditBase') { $ctx.Tag.EditBase = $null }
            if ($ctx.Tag.PSObject.Properties.Name -contains 'BaselineModified') { $ctx.Tag.BaselineModified = $null }
            if ($ctx.Tag.PSObject.Properties.Name -contains 'Draft') { $ctx.Tag.Draft = $null }
            if ($null -ne $this._currentCtx -and ([string]$this._currentCtx.TermId -eq [string]$ctx.TermId)) {
                $this.bindNodeToBox($ctx)
            }
 
            if (-not $this._batchTimer.Enabled) { $this._batchTimer.Start() }
            $this.updateGlobalManager()
            $this.updateSaveButton()
            $this.setStatus("Committing merged result for '$($ctx.DisplayName)' (PID $($forceOp.ProcessId))...")
        } catch { Write-Host "handleConflict - $($_.Exception.Message)" }
    }
 
    hidden [void] killNodeBatch($ctx) {
        try {
            if (($ctx.Tag.PSObject.Properties.Name -contains 'PropUpdate') -and $ctx.Tag.PropUpdate) {
                [void](Stop-SRxTermBatchOperation -Operation $ctx.Tag.PropUpdate)
                $this.setStatus("Killed batch for '$($ctx.DisplayName)'.")
            }
        } catch { Write-Host "killNodeBatch - $($_.Exception.Message)" }
    }
 
    hidden [void] killAllBatches() {
        try {
            foreach ($entry in @($this._activeBatches.GetEnumerator())) {
                try { Stop-SRxTermBatchOperation -Operation $entry.Value.Op | Out-Null } catch {}
            }
            $this.setStatus("Killed all running property batches.")
        } catch { Write-Host "killAllBatches - $($_.Exception.Message)" }
    }
 
    hidden [void] updateGlobalManager() {
        try {
            $active = ($this._activeBatches.Count -gt 0)
            $this._globalMgr.Visible = $active
            if ($null -ne $this._globalMgrBusy) { $this._globalMgrBusy.Visible = $active }
            if ($active) {
                $this._globalMgrLabel.Text = "Properties update in progress ($($this._activeBatches.Count))"
                if (-not $this._spinTimer.Enabled) { $this._spinTimer.Start() }
            } else {
                if ($this._spinTimer.Enabled) { $this._spinTimer.Stop() }
            }
        } catch { Write-Host "updateGlobalManager - $($_.Exception.Message)" }
    }
 
    hidden [void] spinGlobalManager() {
        try {
            $this._globalMgrLabel.Text = "Properties update in progress ($($this._activeBatches.Count))"
        } catch { }
    }
 
    # ========================================================== overlay
 
    hidden [void] showUnsavedOverlay() {
        try {
            $name = ''
            if ($null -ne $this._currentCtx) { $name = [string]$this._currentCtx.DisplayName }
            $this._propOverlayMsg.Text = "You have unsaved property changes for '$name'.`r`nWhat would you like to do?"
            if ($this.Icons.ContainsKey('Save') -and $this.Icons['Save']) { $this._propOverlaySave.Image = $this.Icons['Save'] }
            $this._propOverlay.Dock = [System.Windows.Forms.DockStyle]::Fill
            $this._propOverlay.Visible = $true
            $this._propOverlay.BringToFront()
        } catch { Write-Host "showUnsavedOverlay - $($_.Exception.Message)" }
    }
 
    hidden [void] hideUnsavedOverlay() {
        try { $this._propOverlay.Visible = $false } catch { Write-Host "hideUnsavedOverlay - $($_.Exception.Message)" }
    }
 
    hidden [void] finishPendingAction() {
        try {
            $action = $this._pendingAction
            $this._pendingAction = ''
            if ($action -eq 'switch') {
                $target = $this._pendingTargetCtx
                $this._pendingTargetCtx = $null
                if ($null -ne $target -and $this.OnNavigate) { & $this.OnNavigate $target.Token }
            }
            elseif ($action -eq 'close') {
                $this.doClose()
            }
        } catch { Write-Host "finishPendingAction - $($_.Exception.Message)" }
    }
 
    hidden [void] onOverlaySave() {
        try {
            $ctx = $this._currentCtx
            $this.hideUnsavedOverlay()
            if ($null -ne $ctx) { $this.startBatchForNode($ctx) }
            $this.finishPendingAction()
        } catch { Write-Host "onOverlaySave - $($_.Exception.Message)" }
    }
 
    hidden [void] onOverlayDiscard() {
        try {
            $ctx = $this._currentCtx
            if ($null -ne $ctx) {
                if ($ctx.Tag.PSObject.Properties.Name -contains 'Draft') { $ctx.Tag.Draft = $null }
                if ($ctx.Tag.PSObject.Properties.Name -contains 'EditBase') { $ctx.Tag.EditBase = $null }
            }
            $this.hideUnsavedOverlay()
            if ($null -ne $ctx) { $this.bindNodeToBox($ctx) }
            $this.finishPendingAction()
        } catch { Write-Host "onOverlayDiscard - $($_.Exception.Message)" }
    }
 
    hidden [void] onOverlayCancel() {
        try {
            $this._pendingTargetCtx = $null
            $this._pendingAction = ''
            $this.hideUnsavedOverlay()
        } catch { Write-Host "onOverlayCancel - $($_.Exception.Message)" }
    }
}
 
function New-SRxPropertyBoxControl {
    [OutputType([object])]
    param()
    return [SRxPropertyBoxControl]::new()
}
 
Export-ModuleMember -Function New-SRxPropertyBoxControl