src/UI/Screens/IntegrateScreen.ps1
|
# IntegrateScreen — Dashboard interactivo del flujo Integrate (replica # IntegrationFlowController del v2). # # Permite al usuario configurar 4 inputs + 1 toggle y después ejecutar 4 pasos # encadenados: # 1. Create new branch from TARGET (with checkout) # 2. Merge SOURCE into the new branch # 3. Push (with or without --no-verify according to toggle) # 4. Detect PR URL on GitHub and show / open # # Diseño: # - 4 inputs editables (TARGET, NAME, SOURCE, NO-VERIFY) + Execute + Cancel = 6 ítems. # - Execute solo aparece cuando IsReadyToExecute() del modelo. # - State machine de modos: list / select-target / select-source / input-name / executing. # - Cada modo BuildLines ramifica el render. class IntegrateScreen { [object] $Theme [object] $Renderer [object] $Primitives [object] $Frame [object] $AppHeader [object] $StatusBar [object] $Git [object] $I18n = $null # Inyectable post-construct. [object] $Repo [object] $Flow = [IntegrationFlow]::new() [int] $SelectedIndex = 0 # 0..3 (inputs), 4 (Execute), 5 (Cancel) [string] $Mode = 'list' # 'list' | 'select-target' | 'select-source' | 'input-name' | 'executing' [bool] $QuitRequested = $false [bool] $UseAltBuffer = $true # Selector compartido para target/source — instancia única reutilizada. [object] $Picker = $null # Input state (input-name) [string] $InputBuffer = '' [string] $InputLabel = '' # Status feedback [string] $StatusMessage = '' [string] $StatusKind = '' # 'ok' | 'error' | '' # Resultado terminal del flow ejecutado (mantener visible hasta que el user # presione algo y la screen vuelva). [bool] $Done = $false [string] $DoneMessage = '' IntegrateScreen($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 } # Abre la pantalla para un repo. Loop hasta que user vuelve o quit. [void] Open([object]$repo) { if ($null -eq $repo) { throw "IntegrateScreen.Open: repo no puede ser null." } $this.Repo = $repo $this.Flow = [IntegrationFlow]::new() $this.SelectedIndex = 0 $this.Mode = 'list' $this.StatusMessage = '' $this.StatusKind = '' $this.Done = $false $this.DoneMessage = '' if ([Console]::IsInputRedirected) { $lines = $this.BuildLines() foreach ($l in $lines) { Write-Host $l } return } # Pre-frame inmediato ANTES de cualquier git call (sincrónico) para que # el usuario vea algo apenas entra (sin esperar a la primera iteración del while). $errOut = [Console]::Error $this.Render($errOut) # Validación previa: uncommitted changes — git lo rechaza igual pero # mejor avisar antes de empezar. if ($this.HasUncommittedChanges()) { $this.SetStatus('cuidado: hay cambios sin commitear (git rechazará el merge)', '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 { } } # Detecta cambios sin commit con git status --porcelain (vacío = clean). hidden [bool] HasUncommittedChanges() { $out = $this.Git.RunGit($this.Repo.Path, @('status', '--porcelain')) if ($null -eq $out) { return $false } return @($out | Where-Object { $_ -and $_.Trim() }).Count -gt 0 } hidden [void] Render([object]$errOut) { # Skipea render en modo no-tty (tests, pipes). El stdin redirected señala # que no estamos en una terminal interactiva — no tiene sentido emitir # ANSI escapes y los stubs UI de tests no implementan los métodos. if ([Console]::IsInputRedirected) { return } $lines = $this.BuildLines() $framePayload = [AnsiService]::BeginSync() + [AnsiService]::MoveTo(1, 1) + ($lines -join "`e[K`n") + "`e[K`e[J" + [AnsiService]::EndSync() $errOut.Write($framePayload) $errOut.Flush() } hidden [string] HandleKey([System.ConsoleKeyInfo]$key) { switch ($this.Mode) { 'list' { return $this.HandleKeyList($key) } 'select-target' { return $this.HandleKeySelector($key, 'target') } 'select-source' { return $this.HandleKeySelector($key, 'source') } 'input-name' { return $this.HandleKeyInputName($key) } 'executing' { return $this.HandleKeyExecuting($key) } } return '' } hidden [string] HandleKeyList([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 'LeftArrow' -or $c -eq 'h') { return 'back' } $maxIdx = $(if ($this.Flow.IsReadyToExecute()) { 5 } else { 4 }) if ($k -eq 'UpArrow' -or $c -eq 'k') { $this.SelectedIndex = if ($this.SelectedIndex -le 0) { $maxIdx } else { $this.SelectedIndex - 1 } return '' } if ($k -eq 'DownArrow' -or $c -eq 'j') { $this.SelectedIndex = if ($this.SelectedIndex -ge $maxIdx) { 0 } else { $this.SelectedIndex + 1 } return '' } if ($k -eq 'Enter') { switch ($this.SelectedIndex) { 0 { $this.OpenTargetSelector(); return '' } 1 { $this.OpenNameInput(); return '' } 2 { $this.OpenSourceSelector(); return '' } 3 { $this.Flow.ToggleNoVerify(); return '' } 4 { if ($this.Flow.IsReadyToExecute()) { $this.ExecuteFlow() } return '' } 5 { # Cancel → back. SelectedIndex=5 solo aparece cuando IsReady, # entonces siempre que llegue acá es Cancel. return 'back' } } } return '' } # Lazy-init para tests que invocan OpenTargetSelector sin Open(). hidden [void] EnsurePicker() { if ($null -eq $this.Picker) { $this.Picker = [FilteredListPicker]::new() } } [void] OpenTargetSelector() { $this.SetStatus('cargando remotos…', 'ok') $this.EnsurePicker() $this.Picker.Title = 'TARGET (Remote)' $this.Picker.SetItems(@($this.Git.GetRemoteBranches($this.Repo.Path))) $this.Picker.Reset() $this.Mode = 'select-target' $this.SetStatus('', '') } [void] OpenSourceSelector() { $this.EnsurePicker() $branches = @($this.Git.GetBranches($this.Repo.Path)) $this.Picker.Title = 'SOURCE (Local)' $this.Picker.SetItems(@($branches | ForEach-Object Name)) $this.Picker.Reset() $this.Mode = 'select-source' } hidden [void] OpenNameInput() { $this.InputBuffer = $this.Flow.NewBranchName $this.InputLabel = 'BRANCH NAME' $this.Mode = 'input-name' } # Selector compartido para target/source. $kind decide qué setter del modelo invocar. hidden [string] HandleKeySelector([System.ConsoleKeyInfo]$key, [string]$kind) { $c = $key.KeyChar if ($c -eq 'q' -or $c -eq 'Q') { return 'quit' } $action = $this.Picker.HandleKey($key) if ($action -eq 'enter') { $value = $this.Picker.Selected() if ($null -ne $value) { if ($kind -eq 'target') { $this.Flow.SetTarget([string]$value) } else { $this.Flow.SetSource([string]$value) } $this.Picker.Reset() $this.Mode = 'list' } } elseif ($action -eq 'escape') { # Esc con filter activo limpia filter; con filter vacío vuelve al list. if ($this.Picker.Filter) { $this.Picker.Reset() } else { $this.Mode = 'list' } } return '' } hidden [string] HandleKeyInputName([System.ConsoleKeyInfo]$key) { $k = $key.Key $c = $key.KeyChar if ($k -eq 'Escape') { $this.InputBuffer = '' $this.Mode = 'list' return '' } if ($k -eq 'Enter') { $this.Flow.SetName($this.InputBuffer) $this.InputBuffer = '' $this.Mode = 'list' return '' } if ($k -eq 'Backspace') { if ($this.InputBuffer.Length -gt 0) { $this.InputBuffer = $this.InputBuffer.Substring(0, $this.InputBuffer.Length - 1) } return '' } if (-not [char]::IsControl($c)) { $this.InputBuffer += $c } return '' } hidden [string] HandleKeyExecuting([System.ConsoleKeyInfo]$key) { # Después de Execute, esperamos cualquier tecla para volver. Q quit. $c = $key.KeyChar if ($c -eq 'q' -or $c -eq 'Q') { return 'quit' } return 'back' } # Ejecuta los 4 pasos del flow secuencialmente. Cada paso flashea un status # y, si falla, deja el error visible y termina (sin revertir lo hecho — # el user resuelve manualmente). hidden [void] ExecuteFlow() { $this.Mode = 'executing' $errOut = [Console]::Error $target = $this.Flow.TargetBranch $name = $this.Flow.NewBranchName $source = $this.Flow.SourceBranch $noVer = $this.Flow.NoVerifyPush # `$repo` chocaría con la property $this.Repo (PS classes case-insensitive). $repoPath = $this.Repo.Path # 1. Create branch from target $this.SetStatus("creating $name from $target…", 'ok') $this.Render($errOut) # Para crear desde un remote: `git checkout -b $name $target` (GitService.CreateBranch # usa HEAD actual como base, no nos sirve). Usamos RunGitCapturingStderr directo. $rCreate = $this.Git.RunGitCapturingStderr($repoPath, @('checkout', '-b', $name, $target)) if ($rCreate.ExitCode -ne 0) { $msg = $this.Git.FirstStderrLine($rCreate, "create falló (exit $($rCreate.ExitCode))") $this.FinishWithError("create falló: $msg") return } $this.Git.InvalidatePath($repoPath) # 2. Merge source $this.SetStatus("merging $source…", 'ok') $this.Render($errOut) $rMerge = $this.Git.MergeBranch($repoPath, $source) if (-not $rMerge.Ok) { $this.FinishWithError("merge falló: $($rMerge.Message)") return } # 3. Push (con o sin --no-verify según toggle) $pushLabel = if ($noVer) { 'pushing (--no-verify)…' } else { 'pushing…' } $this.SetStatus($pushLabel, 'ok') $this.Render($errOut) $rPush = $this.Git.Push($repoPath, $noVer) if (-not $rPush.Ok) { $this.FinishWithError("push falló: $($rPush.Message)") return } # 4. Detectar PR URL $prUrl = $this.Git.GetPullRequestUrl($repoPath, $name) if ($prUrl) { $this.FinishWithSuccess("done — PR url: $prUrl") } else { $this.FinishWithSuccess("done — branch $name pusheada (no es github)") } } hidden [void] FinishWithError([string]$msg) { $this.Done = $true $this.DoneMessage = $msg $this.SetStatus($msg, 'error') } hidden [void] FinishWithSuccess([string]$msg) { $this.Done = $true $this.DoneMessage = $msg $this.SetStatus($msg, 'ok') } hidden [void] SetStatus([string]$msg, [string]$kind) { $this.StatusMessage = $msg $this.StatusKind = $kind } [string[]] BuildLines() { $reset = [AnsiService]::Reset $r = $this.Renderer $lines = [System.Collections.Generic.List[string]]::new() # 1. Title bar $repoName = if ($this.Repo) { $this.Repo.Name } else { '—' } $lines.Add($this.Frame.TitleBar('repo-nav', "Integrate · $repoName", '1.0.0')) # 2. AppHeader: breadcrumb terminado en 'Integrate' $bc = [BreadcrumbBuilder]::Build($this.Repo.Path) $segs = @($bc.Segs) + @($bc.Current) + @('Branches') $lines.Add($this.AppHeader.Render($segs, 'Integrate', @())) $lines.Add($r.HRule()) # Cuerpo según modo if ($this.Mode -eq 'select-target' -or $this.Mode -eq 'select-source') { $this.AppendSelectorBody($lines, $r, $reset) } elseif ($this.Mode -eq 'input-name') { $this.AppendDashboardBody($lines, $r, $reset) # Override de la línea de status con el prompt input. } else { # 'list' o 'executing' $this.AppendDashboardBody($lines, $r, $reset) } # StatusBar $hints = $this.StatusBarHints() $lines.Add($r.HRule()) return @($lines.ToArray()) + $this.StatusBar.RenderAnchored($hints, '', $lines.Count) } hidden [void] AppendDashboardBody([object]$lines, [object]$r, [string]$reset) { # Status / prompt line $lines.Add($this.RenderStatusOrPrompt()) # Section title $lines.Add($this.Theme.Fg('fg2') + ' Configuración del flow' + $reset) $lines.Add($r.HRule()) # 4 input rows $lines.Add($this.RenderInputRow(0, 'TARGET (Remote)', $this.Flow.TargetBranch, $this.Flow.TargetValid)) $lines.Add($this.RenderInputRow(1, 'BRANCH NAME', $this.Flow.NewBranchName, $this.Flow.NameValid)) $lines.Add($this.RenderInputRow(2, 'SOURCE (Local)', $this.Flow.SourceBranch, $this.Flow.SourceValid)) $lines.Add($this.RenderToggleRow(3, 'NO-VERIFY PUSH', $this.Flow.NoVerifyPush)) $lines.Add('') $lines.Add($r.HRule()) # Action buttons (Execute solo si IsReady) $canExecute = $this.Flow.IsReadyToExecute() if ($canExecute) { $lines.Add($this.RenderButton(4, '[ Execute ]', 'gitClean')) } $cancelIdx = if ($canExecute) { 5 } else { 4 } $lines.Add($this.RenderButton($cancelIdx, '[ Cancel ]', 'fg2')) } hidden [void] AppendSelectorBody([object]$lines, [object]$r, [string]$reset) { $maxVisible = [Math]::Max(5, [Console]::WindowHeight - 12) $body = $this.Picker.BuildBody($this.Theme, $r, $maxVisible) foreach ($l in $body) { $lines.Add($l) } } hidden [string] RenderStatusOrPrompt() { $reset = [AnsiService]::Reset if ($this.Mode -eq 'input-name') { $cursor = $this.Theme.Fg('acc') + '_' + $reset $label = $this.Theme.Fg('fg2') + ($this.InputLabel + ': ') + $reset $buf = $this.Theme.Fg('fg1') + $this.InputBuffer + $reset return ' ' + $label + $buf + $cursor } if ($this.StatusMessage) { $color = if ($this.StatusKind -eq 'error') { 'gitConflict' } else { 'gitClean' } return ' ' + $this.Theme.Fg($color) + $this.StatusMessage + $reset } return '' } hidden [string] RenderInputRow([int]$idx, [string]$label, [string]$value, [bool]$valid) { $reset = [AnsiService]::Reset $sel = ($this.SelectedIndex -eq $idx -and $this.Mode -eq 'list') $marker = if ($sel) { $this.Theme.Fg('acc') + '▎ ▶ ' + $reset } else { ' · ' } $labelFmt = [Renderer]::PadRight($this.Theme.Fg('fg2') + $label + $reset, 22) $valFg = if ($valid) { 'fg0' } else { 'fg3' } $valText = if ($value) { $value } else { '(vacío)' } $valFmt = $this.Theme.Fg($valFg) + $valText + $reset $tick = if ($valid) { $this.Theme.Fg('gitClean') + '✓' + $reset } else { '' } return $marker + $labelFmt + ' ' + $valFmt + ' ' + $tick } hidden [string] RenderToggleRow([int]$idx, [string]$label, [bool]$value) { $reset = [AnsiService]::Reset $sel = ($this.SelectedIndex -eq $idx -and $this.Mode -eq 'list') $marker = if ($sel) { $this.Theme.Fg('acc') + '▎ ▶ ' + $reset } else { ' · ' } $labelFmt = [Renderer]::PadRight($this.Theme.Fg('fg2') + $label + $reset, 22) $box = if ($value) { '[x]' } else { '[ ]' } $stateText = if ($value) { 'on' } else { 'off' } $stateFg = if ($value) { 'gitClean' } else { 'fg3' } return $marker + $labelFmt + ' ' + $this.Theme.Fg($stateFg) + ($box + ' ' + $stateText) + $reset } hidden [string] RenderButton([int]$idx, [string]$label, [string]$colorKey) { $reset = [AnsiService]::Reset $sel = ($this.SelectedIndex -eq $idx -and $this.Mode -eq 'list') if ($sel) { $bg = $this.Theme.Bg('acc') $fg = $this.Theme.Fg('bg0') return ' ▎ ' + $bg + $fg + ' ' + $label + ' ' + $reset } return ' ' + $this.Theme.Fg($colorKey) + $label + $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-target' { return @( @{ k = '↑↓'; label = $this.T('hint.move') } @{ k = $this.T('hint.type'); label = $this.T('hint.search') } @{ k = '↵'; label = $this.T('hint.accept') } @{ k = 'Esc'; label = $this.T('hint.cancel') } ) } 'select-source' { return @( @{ k = '↑↓'; label = $this.T('hint.move') } @{ k = $this.T('hint.type'); label = $this.T('hint.search') } @{ k = '↵'; label = $this.T('hint.accept') } @{ k = 'Esc'; label = $this.T('hint.cancel') } ) } 'input-name' { return @( @{ k = $this.T('hint.type'); label = $this.T('hint.save') } @{ k = '↵'; label = $this.T('hint.accept') } @{ k = 'Esc'; label = $this.T('hint.cancel') } ) } 'executing' { return @( @{ k = '*'; label = $this.T('hint.back') } @{ k = 'Q'; label = $this.T('hint.quit') } ) } default { return @( @{ k = '↑↓'; label = $this.T('hint.move') } @{ k = '↵'; label = 'Edit/Run' } @{ k = '←'; label = $this.T('hint.back') } @{ k = 'Q'; label = $this.T('hint.quit') } ) } } return @() } } |