PropertyBox/SRxTermMerge.psm1
|
<# ================================================================================ SRxTermMerge.psm1 (Iteration 3) ----------------------------------------------------------------------------- True 3-way merge for term custom properties, replacing the 2-way Overwrite/Discard prompt. Three legs: BASE = the committed property set the user STARTED editing from (node.Tag.EditBase, captured at edit-start). MINE = the user's desired set (box contents, logical/coalesced). THEIRS = current tenant state at conflict time (worker conflict payload: st.Data.RemoteProps). All comparisons run on LOGICAL (coalesced) values; re-chunking happens later at commit via Split-SRxChunkedProperties (in SRxPropertyBox.psm1). This module only deals in logical hashtables. Public surface: Merge-SRxTermProperties -Base -Mine -Theirs -> { Merged : hashtable (logical) - auto+resolved Rows : object[] per-key classification HasConflicts : bool (any blocking row unresolved) ConflictCount : int } Show-SRxMergeDialog -MergeResult -TermName -> hashtable (merged logical) OR $null on cancel Row classification (Class): Identical | MineClean | TheirsClean | AgreeSame | MineAdd | TheirsAdd | MineDelete | TheirsDelete | ConflictEdit | ConflictDelete | ConflictAdd Blocking (needs user) = ConflictEdit | ConflictDelete | ConflictAdd ================================================================================ #> # Sentinel that is distinct from any real string value (incl. empty string), # used to represent "key absent in this leg". $script:SRxAbsent = [pscustomobject]@{ __SRxAbsent__ = $true } function Test-SRxAbsent { param($v) return ($v -is [pscustomobject] -and $v.PSObject.Properties.Name -contains '__SRxAbsent__') } function Get-SRxLegValue { param([hashtable]$Ht, [string]$Key) if ($Ht.ContainsKey($Key)) { return [string]$Ht[$Key] } return $script:SRxAbsent } function Test-SRxEqual { param($a, $b) $aAbsent = Test-SRxAbsent $a $bAbsent = Test-SRxAbsent $b if ($aAbsent -and $bAbsent) { return $true } if ($aAbsent -or $bAbsent) { return $false } return ([string]$a -eq [string]$b) } # ------------------------------------------------------------------------------ # Core 3-way classification. # ------------------------------------------------------------------------------ function Merge-SRxTermProperties { [CmdletBinding()] param( [Parameter(Mandatory=$false)] [hashtable]$Base = @{}, [Parameter(Mandatory=$false)] [hashtable]$Mine = @{}, [Parameter(Mandatory=$false)] [hashtable]$Theirs = @{} ) if ($null -eq $Base) { $Base = @{} } if ($null -eq $Mine) { $Mine = @{} } if ($null -eq $Theirs) { $Theirs = @{} } $allKeys = New-Object System.Collections.Generic.HashSet[string] foreach ($k in $Base.Keys) { [void]$allKeys.Add([string]$k) } foreach ($k in $Mine.Keys) { [void]$allKeys.Add([string]$k) } foreach ($k in $Theirs.Keys) { [void]$allKeys.Add([string]$k) } $rows = @() $merged = @{} foreach ($key in ($allKeys | Sort-Object)) { $bV = Get-SRxLegValue -Ht $Base -Key $key $mV = Get-SRxLegValue -Ht $Mine -Key $key $tV = Get-SRxLegValue -Ht $Theirs -Key $key $mineChanged = -not (Test-SRxEqual $bV $mV) $theirsChanged = -not (Test-SRxEqual $bV $tV) $class = $null $auto = $true $resolved = $script:SRxAbsent # absent == delete/omit from merged set if (-not $mineChanged -and -not $theirsChanged) { # neither side touched it if (Test-SRxAbsent $bV) { $class = 'Identical'; $resolved = $script:SRxAbsent } else { $class = 'Identical'; $resolved = $bV } } elseif ($mineChanged -and -not $theirsChanged) { # only I changed it if (Test-SRxAbsent $mV) { $class = 'MineDelete'; $resolved = $script:SRxAbsent } elseif (Test-SRxAbsent $bV) { $class = 'MineAdd'; $resolved = $mV } else { $class = 'MineClean'; $resolved = $mV } } elseif (-not $mineChanged -and $theirsChanged) { # only they changed it if (Test-SRxAbsent $tV) { $class = 'TheirsDelete'; $resolved = $script:SRxAbsent } elseif (Test-SRxAbsent $bV) { $class = 'TheirsAdd'; $resolved = $tV } else { $class = 'TheirsClean'; $resolved = $tV } } else { # both changed if (Test-SRxEqual $mV $tV) { # converged on the same value (incl. both deleted) if (Test-SRxAbsent $mV) { $class = 'AgreeSame'; $resolved = $script:SRxAbsent } else { $class = 'AgreeSame'; $resolved = $mV } } else { $auto = $false if ((Test-SRxAbsent $mV) -or (Test-SRxAbsent $tV)) { $class = 'ConflictDelete' } elseif (Test-SRxAbsent $bV) { $class = 'ConflictAdd' } else { $class = 'ConflictEdit' } $resolved = $script:SRxAbsent # undecided until user picks } } $rows += [pscustomobject]@{ Key = $key Base = $bV Theirs = $tV Mine = $mV Class = $class Auto = $auto Chosen = if ($auto) { 'Auto' } else { $null } # user picks: 'Mine'|'Theirs'|'Edit' EditText = if (-not (Test-SRxAbsent $mV)) { [string]$mV } else { '' } Resolved = $resolved } if ($auto -and -not (Test-SRxAbsent $resolved)) { $merged[$key] = [string]$resolved } } $conflicts = @($rows | Where-Object { -not $_.Auto }) return [pscustomobject]@{ Merged = $merged Rows = $rows HasConflicts = ($conflicts.Count -gt 0) ConflictCount = $conflicts.Count } } # ------------------------------------------------------------------------------ # Helper: display text for an (absent-aware) value. # ------------------------------------------------------------------------------ function Format-SRxLeg { param($v) if (Test-SRxAbsent $v) { return '(absent)' } if ([string]::IsNullOrEmpty([string]$v)) { return '(empty)' } return [string]$v } # ------------------------------------------------------------------------------ # Recompute the merged set from current row choices; returns @{ Merged; Complete }. # ------------------------------------------------------------------------------ function Resolve-SRxMergeRows { param([Parameter(Mandatory)] $Rows) $merged = @{} $complete = $true foreach ($r in $Rows) { if ($r.Auto) { if (-not (Test-SRxAbsent $r.Resolved)) { $merged[$r.Key] = [string]$r.Resolved } continue } switch ($r.Chosen) { 'Mine' { if (-not (Test-SRxAbsent $r.Mine)) { $merged[$r.Key] = [string]$r.Mine } } 'Theirs' { if (-not (Test-SRxAbsent $r.Theirs)) { $merged[$r.Key] = [string]$r.Theirs } } 'Edit' { $merged[$r.Key] = [string]$r.EditText } default { $complete = $false } # still undecided } } return [pscustomobject]@{ Merged = $merged; Complete = $complete } } # ------------------------------------------------------------------------------ # Modal WinForms resolution dialog. Returns merged logical hashtable, or $null. # Only shown when $MergeResult.HasConflicts is true (caller enforces). # ------------------------------------------------------------------------------ function Show-SRxMergeDialog { [CmdletBinding()] param( [Parameter(Mandatory)] $MergeResult, [Parameter(Mandatory=$false)] [string]$TermName = 'term', [Parameter(Mandatory=$false)] [string]$RemoteModified = '', [Parameter(Mandatory=$false)] [string]$BaselineModified = '' ) Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue Add-Type -AssemblyName System.Drawing -ErrorAction SilentlyContinue $rows = $MergeResult.Rows $form = [System.Windows.Forms.Form]::new() $form.Text = "Resolve property conflicts - $TermName" $form.Width = 900 $form.Height = 520 $form.StartPosition = [System.Windows.Forms.FormStartPosition]::CenterParent $form.MinimizeBox = $false $form.MaximizeBox = $false $form.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::Sizable $header = [System.Windows.Forms.Label]::new() $header.AutoSize = $false $header.Dock = [System.Windows.Forms.DockStyle]::Top $header.Height = 40 $header.Padding = [System.Windows.Forms.Padding]::new(8, 6, 8, 0) $ht = "Both you and the tenant changed '$TermName'." if ($RemoteModified) { $ht += " Remote modified: $RemoteModified" } if ($BaselineModified) { $ht += " (your baseline: $BaselineModified)" } $ht += " Resolve the conflicting keys below, then Commit merged." $header.Text = $ht $form.Controls.Add($header) # bottom button bar $bar = [System.Windows.Forms.Panel]::new() $bar.Dock = [System.Windows.Forms.DockStyle]::Bottom $bar.Height = 84 $form.Controls.Add($bar) $preview = [System.Windows.Forms.TextBox]::new() $preview.Multiline = $true $preview.ReadOnly = $true $preview.ScrollBars = [System.Windows.Forms.ScrollBars]::Vertical $preview.Dock = [System.Windows.Forms.DockStyle]::Top $preview.Height = 44 $preview.BackColor = [System.Drawing.SystemColors]::Info $bar.Controls.Add($preview) $btnCommit = [System.Windows.Forms.Button]::new() $btnCommit.Text = 'Commit merged' $btnCommit.Width = 130 $btnCommit.Height = 28 $btnCommit.Location = [System.Drawing.Point]::new(620, 50) $bar.Controls.Add($btnCommit) $btnCancel = [System.Windows.Forms.Button]::new() $btnCancel.Text = 'Cancel' $btnCancel.Width = 90 $btnCancel.Height = 28 $btnCancel.Location = [System.Drawing.Point]::new(760, 50) $btnCancel.DialogResult = [System.Windows.Forms.DialogResult]::Cancel $bar.Controls.Add($btnCancel) $btnAllMine = [System.Windows.Forms.Button]::new() $btnAllMine.Text = 'Take all Mine' $btnAllMine.Width = 110 $btnAllMine.Height = 28 $btnAllMine.Location = [System.Drawing.Point]::new(8, 50) $bar.Controls.Add($btnAllMine) $btnAllTheirs = [System.Windows.Forms.Button]::new() $btnAllTheirs.Text = 'Take all Theirs' $btnAllTheirs.Width = 110 $btnAllTheirs.Height = 28 $btnAllTheirs.Location = [System.Drawing.Point]::new(124, 50) $bar.Controls.Add($btnAllTheirs) $chkHideAuto = [System.Windows.Forms.CheckBox]::new() $chkHideAuto.Text = 'Hide auto-resolved rows' $chkHideAuto.AutoSize = $true $chkHideAuto.Location = [System.Drawing.Point]::new(250, 55) $bar.Controls.Add($chkHideAuto) # grid $grid = [System.Windows.Forms.DataGridView]::new() $grid.Dock = [System.Windows.Forms.DockStyle]::Fill $grid.AllowUserToAddRows = $false $grid.AllowUserToDeleteRows = $false $grid.RowHeadersVisible = $false $grid.SelectionMode = [System.Windows.Forms.DataGridViewSelectionMode]::CellSelect $grid.AutoSizeColumnsMode = [System.Windows.Forms.DataGridViewAutoSizeColumnsMode]::None $form.Controls.Add($grid) $grid.BringToFront() $colKey = [System.Windows.Forms.DataGridViewTextBoxColumn]::new() $colKey.HeaderText = 'Key'; $colKey.Width = 130; $colKey.ReadOnly = $true [void]$grid.Columns.Add($colKey) $colBase = [System.Windows.Forms.DataGridViewTextBoxColumn]::new() $colBase.HeaderText = 'Base (ancestor)'; $colBase.Width = 150; $colBase.ReadOnly = $true [void]$grid.Columns.Add($colBase) $colTheirs = [System.Windows.Forms.DataGridViewTextBoxColumn]::new() $colTheirs.HeaderText = 'Theirs (remote)'; $colTheirs.Width = 150; $colTheirs.ReadOnly = $true [void]$grid.Columns.Add($colTheirs) $colMine = [System.Windows.Forms.DataGridViewTextBoxColumn]::new() $colMine.HeaderText = 'Mine (yours)'; $colMine.Width = 150; $colMine.ReadOnly = $true [void]$grid.Columns.Add($colMine) $colChoice = [System.Windows.Forms.DataGridViewComboBoxColumn]::new() $colChoice.HeaderText = 'Resolution'; $colChoice.Width = 90 [void]$colChoice.Items.AddRange(@('Theirs','Mine','Edit')) $colChoice.FlatStyle = [System.Windows.Forms.FlatStyle]::Flat [void]$grid.Columns.Add($colChoice) $colEdit = [System.Windows.Forms.DataGridViewTextBoxColumn]::new() $colEdit.HeaderText = 'Edit value'; $colEdit.Width = 150 [void]$grid.Columns.Add($colEdit) # map grid row index -> merge row object $rowMap = @{} $script:refreshPreview = { $state = Resolve-SRxMergeRows -Rows $rows $parts = @() foreach ($k in ($state.Merged.Keys | Sort-Object)) { $parts += "$k=$($state.Merged[$k])" } if ($parts.Count -eq 0) { $preview.Text = 'Merged preview: (all properties removed)' } else { $preview.Text = 'Merged preview: ' + ($parts -join ' | ') } $btnCommit.Enabled = $state.Complete }.GetNewClosure() $populate = { $grid.Rows.Clear() $rowMap.Clear() foreach ($r in $rows) { if ($chkHideAuto.Checked -and $r.Auto) { continue } $choiceVal = if ($r.Auto) { '' } elseif ($r.Chosen) { $r.Chosen } else { '' } $editVal = if ($r.Chosen -eq 'Edit') { [string]$r.EditText } elseif (-not (Test-SRxAbsent $r.Mine)) { [string]$r.Mine } else { '' } $idx = $grid.Rows.Add( $r.Key, (Format-SRxLeg $r.Base), (Format-SRxLeg $r.Theirs), (Format-SRxLeg $r.Mine), $choiceVal, $editVal ) $gr = $grid.Rows[$idx] $rowMap[$idx] = $r if ($r.Auto) { $gr.DefaultCellStyle.BackColor = [System.Drawing.Color]::FromArgb(240,240,240) $gr.DefaultCellStyle.ForeColor = [System.Drawing.Color]::Gray $gr.Cells[4].ReadOnly = $true $gr.Cells[5].ReadOnly = $true } else { $gr.Cells[0].Style.ForeColor = [System.Drawing.Color]::DarkRed $gr.Cells[0].Style.Font = [System.Drawing.Font]::new($grid.Font, [System.Drawing.FontStyle]::Bold) $gr.Cells[5].ReadOnly = ($r.Chosen -ne 'Edit') } } & $script:refreshPreview }.GetNewClosure() $grid.add_CellValueChanged({ param($s, $e) if ($e.RowIndex -lt 0) { return } if (-not $rowMap.ContainsKey($e.RowIndex)) { return } $r = $rowMap[$e.RowIndex] if ($r.Auto) { return } if ($e.ColumnIndex -eq 4) { $val = [string]$s.Rows[$e.RowIndex].Cells[4].Value $r.Chosen = $val $s.Rows[$e.RowIndex].Cells[5].ReadOnly = ($val -ne 'Edit') if ($val -eq 'Mine' -and -not (Test-SRxAbsent $r.Mine)) { $s.Rows[$e.RowIndex].Cells[5].Value = [string]$r.Mine } if ($val -eq 'Theirs' -and -not (Test-SRxAbsent $r.Theirs)) { $s.Rows[$e.RowIndex].Cells[5].Value = [string]$r.Theirs } } elseif ($e.ColumnIndex -eq 5) { $r.EditText = [string]$s.Rows[$e.RowIndex].Cells[5].Value } & $script:refreshPreview }) # commit combo edits immediately $grid.add_CurrentCellDirtyStateChanged({ param($s, $e) if ($s.IsCurrentCellDirty) { $s.CommitEdit([System.Windows.Forms.DataGridViewDataErrorContexts]::Commit) } }) $btnAllMine.add_click({ foreach ($r in $rows) { if (-not $r.Auto) { $r.Chosen = 'Mine' } } & $populate }.GetNewClosure()) $btnAllTheirs.add_click({ foreach ($r in $rows) { if (-not $r.Auto) { $r.Chosen = 'Theirs' } } & $populate }.GetNewClosure()) $chkHideAuto.add_CheckedChanged({ & $populate }.GetNewClosure()) $script:mergeDialogResult = $null $btnCommit.add_click({ $state = Resolve-SRxMergeRows -Rows $rows if (-not $state.Complete) { [void][System.Windows.Forms.MessageBox]::Show('Every conflicting key needs a resolution.', 'Incomplete', [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Information) return } $script:mergeDialogResult = $state.Merged $form.DialogResult = [System.Windows.Forms.DialogResult]::OK $form.Close() }.GetNewClosure()) $form.AcceptButton = $btnCommit $form.CancelButton = $btnCancel & $populate $dr = $form.ShowDialog() if ($dr -eq [System.Windows.Forms.DialogResult]::OK) { return $script:mergeDialogResult } return $null } Export-ModuleMember -Function ` Merge-SRxTermProperties, ` Resolve-SRxMergeRows, ` Show-SRxMergeDialog, ` Format-SRxLeg |