src/UI/Screens/CompareScreen.ps1
|
# CompareScreen — Compare entre dos branches con file list + unified diff. # # 4 modos secuenciales: # 1. select-a — selector branch A (base) # 2. select-b — selector branch B (compare) # 3. select-file — lista de archivos cambiados (con icono A/M/D/R) # 4. view-diff — unified diff con scroll H/V y colorización # # Esc en cada modo retrocede al previo. En select-a, Esc sale. # # El v2 tenía split-pane con syntax highlighting. Esta versión usa unified diff # (más universal, +/- legible en cualquier ancho de terminal). Syntax # highlighting puede sumarse en iteración futura. # # Usa 2 FilteredListPicker: BranchPicker (reusado entre A y B) y FilePicker # (RenderRowFn custom con icono de status). El diff view es scroll puro, no # necesita picker. class CompareScreen { [object] $Theme [object] $Renderer [object] $Primitives [object] $Frame [object] $AppHeader [object] $StatusBar [object] $Git [object] $I18n = $null # Inyectable post-construct. [object] $Repo [string] $BranchA = '' [string] $BranchB = '' [string] $Mode = 'select-a' # Pickers — uno reusado para branches (A y B con reset), otro para files. [object] $BranchPicker = $null [object] $FilePicker = $null # Diff view state. [string] $CurrentFile = '' [string[]] $DiffLines = @() [int] $DiffScrollY = 0 [int] $DiffScrollX = 0 [bool] $QuitRequested = $false [bool] $UseAltBuffer = $true [string] $StatusMessage = '' [string] $StatusKind = '' CompareScreen($theme, $renderer, $primitives, $frame, $appHeader, $statusBar, $git) { $this.Theme = $theme $this.Renderer = $renderer $this.Primitives = $primitives $this.Frame = $frame $this.AppHeader = $appHeader $this.StatusBar = $statusBar $this.Git = $git $this.InitPickers() } hidden [void] InitPickers() { $this.BranchPicker = [FilteredListPicker]::new() $this.BranchPicker.Title = 'Filtrar branch' $this.FilePicker = [FilteredListPicker]::new() $this.FilePicker.Title = 'Filtrar archivos' # Match contra File path. $this.FilePicker.LabelFn = { param($item) [string]$item.File } # Render row con icono A/M/D/R. $this.FilePicker.RenderRowFn = { param($item, $sel, $theme, $reset) $marker = if ($sel) { $theme.Fg('acc') + '▎ ▶ ' + $reset } else { ' · ' } $iconKey = switch ($item.Status) { 'A' { 'gitClean' } 'D' { 'gitConflict' } 'M' { 'gitDirty' } 'R' { 'gitAhead' } default { 'fg2' } } $iconChar = switch ($item.Status) { 'A' { '+' } 'D' { '-' } 'M' { '~' } 'R' { '>' } default { '?' } } $icon = $theme.Fg($iconKey) + "[$iconChar]" + $reset $name = $theme.Fg($(if ($sel) { 'acc' } else { 'fg1' })) + $item.File + $reset return $marker + $icon + ' ' + $name } } [void] Open([object]$repo) { if ($null -eq $repo) { throw "CompareScreen.Open: repo no puede ser null." } $this.Repo = $repo $this.BranchA = '' $this.BranchB = '' $this.Mode = 'select-a' $this.LoadBranches() if ([Console]::IsInputRedirected) { $rendered = $this.BuildLines() foreach ($l in $rendered) { Write-Host $l } return } $errOut = [Console]::Error $this.Render($errOut) try { while ($true) { $key = [Console]::ReadKey($true) $exit = $this.HandleKey($key) if ($exit -eq 'back') { return } if ($exit -eq 'quit') { $this.QuitRequested = $true; return } $this.Render($errOut) } } finally { } } hidden [void] LoadBranches() { $branches = @($this.Git.GetBranches($this.Repo.Path)) $this.BranchPicker.SetItems(@($branches | ForEach-Object Name)) $this.BranchPicker.Reset() } hidden [void] LoadFiles() { $this.SetStatus('cargando diff…', 'ok') $files = @($this.Git.DiffNameStatus($this.Repo.Path, $this.BranchA, $this.BranchB)) $this.FilePicker.SetItems($files) $this.FilePicker.Reset() if ($files.Count -eq 0) { $this.SetStatus("sin diferencias entre $($this.BranchA) y $($this.BranchB)", 'error') } else { $this.SetStatus('', '') } } hidden [void] LoadDiff([string]$file) { $this.SetStatus('cargando archivo…', 'ok') $this.CurrentFile = $file $this.DiffLines = @($this.Git.DiffFile($this.Repo.Path, $this.BranchA, $this.BranchB, $file)) $this.DiffScrollY = 0 $this.DiffScrollX = 0 if ($this.DiffLines.Count -eq 0) { $this.SetStatus('archivo sin diff visible', 'error') } else { $this.SetStatus('', '') } } hidden [void] Render([object]$errOut) { if ([Console]::IsInputRedirected) { return } $rendered = $this.BuildLines() $framePayload = [AnsiService]::BeginSync() + [AnsiService]::MoveTo(1, 1) + ($rendered -join "`e[K`n") + "`e[K`e[J" + [AnsiService]::EndSync() $errOut.Write($framePayload) $errOut.Flush() } hidden [string] HandleKey([System.ConsoleKeyInfo]$key) { switch ($this.Mode) { 'select-a' { return $this.HandleKeyBranchSelector($key, 'a') } 'select-b' { return $this.HandleKeyBranchSelector($key, 'b') } 'select-file' { return $this.HandleKeyFileSelector($key) } 'view-diff' { return $this.HandleKeyDiff($key) } } return '' } hidden [string] HandleKeyBranchSelector([System.ConsoleKeyInfo]$key, [string]$slot) { $c = $key.KeyChar if ($c -eq 'q' -or $c -eq 'Q') { return 'quit' } $action = $this.BranchPicker.HandleKey($key) if ($action -eq 'enter') { $value = $this.BranchPicker.Selected() if ($null -ne $value) { if ($slot -eq 'a') { $this.BranchA = [string]$value $this.Mode = 'select-b' $this.BranchPicker.Reset() } else { $candidate = [string]$value if ($candidate -eq $this.BranchA) { $this.SetStatus('no podés comparar una branch contra sí misma', 'error') return '' } $this.BranchB = $candidate $this.Mode = 'select-file' $this.LoadFiles() } } } elseif ($action -eq 'escape') { if ($this.BranchPicker.Filter) { $this.BranchPicker.Reset() } elseif ($slot -eq 'a') { return 'back' } else { $this.Mode = 'select-a' $this.BranchPicker.Reset() } } return '' } hidden [string] HandleKeyFileSelector([System.ConsoleKeyInfo]$key) { $c = $key.KeyChar if ($c -eq 'q' -or $c -eq 'Q') { return 'quit' } $action = $this.FilePicker.HandleKey($key) if ($action -eq 'enter') { $picked = $this.FilePicker.Selected() if ($null -ne $picked) { $this.LoadDiff($picked.File) $this.Mode = 'view-diff' } } elseif ($action -eq 'escape') { if ($this.FilePicker.Filter) { $this.FilePicker.Reset() } else { $this.Mode = 'select-b' $this.BranchPicker.Reset() } } return '' } hidden [string] HandleKeyDiff([System.ConsoleKeyInfo]$key) { $k = $key.Key $c = $key.KeyChar if ($c -eq 'q' -or $c -eq 'Q') { return 'quit' } if ($k -eq 'Escape' -or $k -eq 'Backspace') { $this.Mode = 'select-file' return '' } $contentH = $this.DiffContentHeight() $maxY = [Math]::Max(0, $this.DiffLines.Count - $contentH) $maxLen = 0 foreach ($l in $this.DiffLines) { if ($l.Length -gt $maxLen) { $maxLen = $l.Length } } $width = $this.Renderer.Width() $maxX = [Math]::Max(0, $maxLen - $width + 4) if ($k -eq 'UpArrow' -or $c -eq 'k') { $this.DiffScrollY = [Math]::Max(0, $this.DiffScrollY - 1); return '' } if ($k -eq 'DownArrow' -or $c -eq 'j') { $this.DiffScrollY = [Math]::Min($maxY, $this.DiffScrollY + 1); return '' } if ($k -eq 'PageUp') { $this.DiffScrollY = [Math]::Max(0, $this.DiffScrollY - ($contentH - 1)); return '' } if ($k -eq 'PageDown') { $this.DiffScrollY = [Math]::Min($maxY, $this.DiffScrollY + ($contentH - 1)); return '' } if ($k -eq 'Home' -or $c -eq 'g') { $this.DiffScrollY = 0; return '' } if ($k -eq 'End' -or $c -eq 'G') { $this.DiffScrollY = $maxY; return '' } if ($k -eq 'LeftArrow' -or $c -eq 'h') { $this.DiffScrollX = [Math]::Max(0, $this.DiffScrollX - 8); return '' } if ($k -eq 'RightArrow' -or $c -eq 'l') { $this.DiffScrollX = [Math]::Min($maxX, $this.DiffScrollX + 8); return '' } return '' } hidden [int] DiffContentHeight() { return [Math]::Max(3, [Console]::WindowHeight - 8) } hidden [void] SetStatus([string]$msg, [string]$kind) { $this.StatusMessage = $msg $this.StatusKind = $kind } [string[]] BuildLines() { $reset = [AnsiService]::Reset $r = $this.Renderer $out = [System.Collections.Generic.List[string]]::new() # Title bar refleja el progreso del flow. $repoName = if ($this.Repo) { $this.Repo.Name } else { '—' } $title = switch ($this.Mode) { 'select-a' { "Compare · $repoName · pick A" } 'select-b' { "Compare · $repoName · A=$($this.BranchA) · pick B" } 'select-file' { "Compare · $repoName · $($this.BranchA) ↔ $($this.BranchB)" } 'view-diff' { "Compare · $repoName · $($this.BranchA) ↔ $($this.BranchB) · $($this.CurrentFile)" } } $out.Add($this.Frame.TitleBar('repo-nav', $title, '1.0.0')) $bc = [BreadcrumbBuilder]::Build($this.Repo.Path) $segs = @($bc.Segs) + @($bc.Current) + @('Branches') $out.Add($this.AppHeader.Render($segs, 'Compare', @())) $out.Add($r.HRule()) # Status / prompt if ($this.StatusMessage) { $color = if ($this.StatusKind -eq 'error') { 'gitConflict' } else { 'gitClean' } $out.Add(' ' + $this.Theme.Fg($color) + $this.StatusMessage + $reset) } else { $out.Add('') } # Cuerpo según modo — pickers tienen su propio prompt + count + lista. $maxVisible = [Math]::Max(5, [Console]::WindowHeight - 13) switch ($this.Mode) { 'select-a' { $this.BranchPicker.Title = 'Branch A (base)' $body = $this.BranchPicker.BuildBody($this.Theme, $r, $maxVisible) foreach ($l in $body) { $out.Add($l) } } 'select-b' { $this.BranchPicker.Title = "Branch B (vs $($this.BranchA))" $body = $this.BranchPicker.BuildBody($this.Theme, $r, $maxVisible) foreach ($l in $body) { $out.Add($l) } } 'select-file' { $body = $this.FilePicker.BuildBody($this.Theme, $r, $maxVisible) foreach ($l in $body) { $out.Add($l) } } 'view-diff' { $this.AppendDiff($out, $r, $reset) } } # StatusBar $hints = $this.StatusBarHints() $out.Add($r.HRule()) return @($out.ToArray()) + $this.StatusBar.RenderAnchored($hints, '', $out.Count) } hidden [void] AppendDiff([object]$out, [object]$r, [string]$reset) { $totalLines = $this.DiffLines.Count $contentH = $this.DiffContentHeight() $titleText = ' Diff · ' + $this.CurrentFile + ' · ' + $totalLines + ' líneas' if ($totalLines -gt $contentH) { $start = $this.DiffScrollY + 1 $end = [Math]::Min($totalLines, $this.DiffScrollY + $contentH) $titleText += " · viendo $start–$end" } $out.Add($this.Theme.Fg('fg2') + $titleText + $reset) $out.Add($r.HRule()) $sliceStart = $this.DiffScrollY $sliceEnd = [Math]::Min($totalLines, $this.DiffScrollY + $contentH) $width = $r.Width() $contentWidth = [Math]::Max(10, $width - 2) if ($totalLines -eq 0) { $out.Add($this.Theme.Fg('fg3') + ' (sin diff)' + $reset) return } for ($i = $sliceStart; $i -lt $sliceEnd; $i++) { $line = $this.DiffLines[$i] # Slice horizontal if ($this.DiffScrollX -lt $line.Length) { $line = $line.Substring($this.DiffScrollX) } else { $line = '' } if ($line.Length -gt $contentWidth) { $line = $line.Substring(0, $contentWidth) } $out.Add(' ' + $this.ColorizeDiffLine($line)) } } hidden [string] ColorizeDiffLine([string]$line) { $reset = [AnsiService]::Reset # Detect prefix para color. if ($line.StartsWith('+++') -or $line.StartsWith('---') -or $line.StartsWith('diff --git') -or $line.StartsWith('index ')) { return $this.Theme.Fg('fg2') + $line + $reset } if ($line.StartsWith('@@')) { return $this.Theme.Fg('gitDirty') + $line + $reset } if ($line.StartsWith('+')) { return $this.Theme.Fg('gitClean') + $line + $reset } if ($line.StartsWith('-')) { return $this.Theme.Fg('gitConflict') + $line + $reset } return $this.Theme.Fg('fg1') + $line + $reset } hidden [string] T([string]$key) { if ($null -ne $this.I18n) { return $this.I18n.T($key) } return ([I18nService]::new('es')).T($key) } hidden [array] StatusBarHints() { switch ($this.Mode) { 'select-a' { return @( @{ k = '↑↓'; label = $this.T('hint.move') } @{ k = $this.T('hint.type'); label = $this.T('hint.search') } @{ k = '↵'; label = 'Pick A' } @{ k = 'Esc'; label = $this.T('hint.back') } @{ k = 'Q'; label = $this.T('hint.quit') } ) } 'select-b' { return @( @{ k = '↑↓'; label = $this.T('hint.move') } @{ k = $this.T('hint.type'); label = $this.T('hint.search') } @{ k = '↵'; label = 'Pick B' } @{ k = 'Esc'; label = $this.T('hint.back') } @{ k = 'Q'; label = $this.T('hint.quit') } ) } 'select-file' { return @( @{ k = '↑↓'; label = $this.T('hint.move') } @{ k = $this.T('hint.type'); label = $this.T('hint.search') } @{ k = '↵'; label = 'Diff' } @{ k = 'Esc'; label = $this.T('hint.back') } @{ k = 'Q'; label = $this.T('hint.quit') } ) } 'view-diff' { return @( @{ k = '↑↓'; label = 'Scroll' } @{ k = '←→'; label = 'Horiz' } @{ k = 'PgUp/PgDn'; label = 'Page' } @{ k = 'g/G'; label = 'Top/End' } @{ k = 'Esc'; label = $this.T('hint.back') } @{ k = 'Q'; label = $this.T('hint.quit') } ) } } return @() } } |