src/UI/Screens/QuickChangeScreen.ps1
|
# QuickChangeScreen — Dashboard contextual del flujo Quick Changes (replica # QuickChangeFlowController del v2). # # A diferencia de IntegrateScreen (configurable con inputs), Quick Changes # muestra una lista DINÁMICA de acciones según el estado actual del repo: # # - Si hay uncommitted changes → Commit All # - Si needsPush (ahead/no upstream) → Push, Push --no-verify # - Si needsPull (behind) → Pull # - Siempre → Switch Branch, Cancel # # Cada Enter ejecuta una acción atómica (Push/Pull) o entra en sub-modo # (input-message para commit, select-branch para switch). class QuickChangeScreen { [object] $Theme [object] $Renderer [object] $Primitives [object] $Frame [object] $AppHeader [object] $StatusBar [object] $Git [object] $I18n = $null # Inyectable post-construct. [object] $Repo [int] $SelectedIndex = 0 [string] $Mode = 'list' # 'list' | 'input-message' | 'select-branch' | 'executing' [bool] $QuitRequested = $false [bool] $UseAltBuffer = $true # Estado calculado en cada Refresh. [bool] $HasChanges = $false [string] $CurrentBranch = '' [bool] $HasRemote = $false [bool] $NeedsPush = $false [bool] $NeedsPull = $false [int] $Ahead = 0 [int] $Behind = 0 # Mensaje del commit (cuando estamos en input-message). [string] $InputBuffer = '' # Selector branches — usa FilteredListPicker componente reusable. [object] $BranchPicker = $null [string] $StatusMessage = '' [string] $StatusKind = '' QuickChangeScreen($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 } [void] Open([object]$repo) { if ($null -eq $repo) { throw "QuickChangeScreen.Open: repo no puede ser null." } $this.Repo = $repo $this.SelectedIndex = 0 $this.Mode = 'list' $this.StatusMessage = '' $this.StatusKind = '' $this.RefreshState() if ([Console]::IsInputRedirected) { $lines = $this.BuildLines() foreach ($l in $lines) { 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 { } } # Recalcula estado: dirty, ahead/behind, current branch, hasRemote. # Invalida el cache primero — Refresh implica "quiero datos frescos". hidden [void] RefreshState() { $repoPath = $this.Repo.Path $this.Git.InvalidatePath($repoPath) $state = $this.Git.GetRepoState($repoPath) $this.CurrentBranch = $state.Branch $this.Ahead = $state.Ahead $this.Behind = $state.Behind $this.HasChanges = ($state.Status -in @('dirty', 'conflict')) # NeedsPull: behind > 0. $this.NeedsPull = ($state.Behind -gt 0) # HasRemote: hay url configurada. $url = $this.Git.RunGit($repoPath, @('config', '--get', 'remote.origin.url')) $this.HasRemote = ($url -and $url[0]) # NeedsPush: tiene remote y (ahead > 0 o branch local sin tracking). if ($this.HasRemote) { if ($state.Ahead -gt 0) { $this.NeedsPush = $true } else { # Sin upstream → tampoco está sincronizada todavía. $hasUp = $this.Git.RunGit($repoPath, @('rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}')) $this.NeedsPush = -not ($hasUp -and $hasUp[0]) } } else { $this.NeedsPush = $false } } # Lista de acciones disponibles según estado actual. Cada item es un hashtable # @{ Label; Action }. La SelectedIndex apunta al índice de este array. [object[]] AvailableActions() { $items = @() if ($this.HasChanges) { $items += @{ Label = 'Commit todos los cambios'; Action = 'commit' } } if ($this.NeedsPush) { $items += @{ Label = 'Push'; Action = 'push' } $items += @{ Label = 'Push --no-verify'; Action = 'push-no-verify' } } if ($this.NeedsPull) { $items += @{ Label = 'Pull'; Action = 'pull' } } $items += @{ Label = 'Switch Branch'; Action = 'switch' } $items += @{ Label = 'Cancel'; Action = 'cancel' } return $items } hidden [void] Render([object]$errOut) { 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) } 'input-message' { return $this.HandleKeyInputMessage($key) } 'select-branch' { return $this.HandleKeyBranchSelector($key) } 'executing' { return $this.HandleKeyExecuting($key) } } return '' } hidden [string] HandleKeyList([System.ConsoleKeyInfo]$key) { $k = $key.Key $c = $key.KeyChar $items = $this.AvailableActions() if ($c -eq 'q' -or $c -eq 'Q') { return 'quit' } if ($k -eq 'Escape' -or $k -eq 'LeftArrow' -or $c -eq 'h') { return 'back' } if ($k -eq 'UpArrow' -or $c -eq 'k') { if ($items.Count -gt 0) { $this.SelectedIndex = if ($this.SelectedIndex -le 0) { $items.Count - 1 } else { $this.SelectedIndex - 1 } } return '' } if ($k -eq 'DownArrow' -or $c -eq 'j') { if ($items.Count -gt 0) { $this.SelectedIndex = if ($this.SelectedIndex -ge $items.Count - 1) { 0 } else { $this.SelectedIndex + 1 } } return '' } if ($k -eq 'Enter') { if ($items.Count -eq 0) { return '' } $action = $items[$this.SelectedIndex].Action switch ($action) { 'commit' { $this.OpenCommitInput(); return '' } 'push' { $this.DoPush($false); return '' } 'push-no-verify' { $this.DoPush($true); return '' } 'pull' { $this.DoPull(); return '' } 'switch' { $this.OpenBranchSelector(); return '' } 'cancel' { return 'back' } } } return '' } hidden [void] OpenCommitInput() { $this.InputBuffer = '' $this.Mode = 'input-message' } hidden [void] OpenBranchSelector() { $branches = @($this.Git.GetBranches($this.Repo.Path)) $names = @($branches | ForEach-Object Name) $this.BranchPicker = [FilteredListPicker]::new() $this.BranchPicker.Title = 'Switch a' $this.BranchPicker.SetItems($names) $this.Mode = 'select-branch' } hidden [string] HandleKeyInputMessage([System.ConsoleKeyInfo]$key) { $k = $key.Key $c = $key.KeyChar if ($k -eq 'Escape') { $this.InputBuffer = '' $this.Mode = 'list' return '' } if ($k -eq 'Enter') { $msg = $this.InputBuffer.Trim() $this.InputBuffer = '' $this.Mode = 'list' if (-not $msg) { $this.SetStatus('mensaje vacío — cancelado', 'error') return '' } $this.DoCommit($msg) 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] HandleKeyBranchSelector([System.ConsoleKeyInfo]$key) { $c = $key.KeyChar $k = $key.Key if ($c -eq 'q' -or $c -eq 'Q') { return 'quit' } if ($k -eq 'LeftArrow') { $this.Mode = 'list'; return '' } $action = $this.BranchPicker.HandleKey($key) if ($action -eq 'enter') { $picked = $this.BranchPicker.Selected() $this.Mode = 'list' if ($picked) { $this.DoSwitch([string]$picked) } } elseif ($action -eq 'escape') { $this.Mode = 'list' } return '' } hidden [string] HandleKeyExecuting([System.ConsoleKeyInfo]$key) { $c = $key.KeyChar if ($c -eq 'q' -or $c -eq 'Q') { return 'quit' } return 'back' } hidden [void] DoCommit([string]$message) { $this.SetStatus('staging y commiteando…', 'ok') $rAdd = $this.Git.Add($this.Repo.Path, '.') if (-not $rAdd.Ok) { $this.SetStatus("add falló: $($rAdd.Message)", 'error') return } $rCommit = $this.Git.Commit($this.Repo.Path, $message) if ($rCommit.Ok) { $this.SetStatus("commit OK", 'ok') $this.RefreshState() } else { $this.SetStatus("commit falló: $($rCommit.Message)", 'error') } } hidden [void] DoPush([bool]$noVerify) { $r = $this.Git.Push($this.Repo.Path, $noVerify) $this.SetStatus($r.Message, ($(if ($r.Ok) { 'ok' } else { 'error' }))) if ($r.Ok) { $this.RefreshState() } } hidden [void] DoPull() { $r = $this.Git.Pull($this.Repo.Path) $this.SetStatus($r.Message, ($(if ($r.Ok) { 'ok' } else { 'error' }))) if ($r.Ok) { $this.RefreshState() } } hidden [void] DoSwitch([string]$branchName) { $r = $this.Git.CheckoutBranch($this.Repo.Path, $branchName) $this.SetStatus($r.Message, ($(if ($r.Ok) { 'ok' } else { 'error' }))) if ($r.Ok) { $this.RefreshState() } } 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() # Title bar $repoName = if ($this.Repo) { $this.Repo.Name } else { '—' } $lines.Add($this.Frame.TitleBar('repo-nav', "Quick Changes · $repoName", '1.0.0')) # AppHeader $bc = [BreadcrumbBuilder]::Build($this.Repo.Path) $segs = @($bc.Segs) + @($bc.Current) + @('Branches') $lines.Add($this.AppHeader.Render($segs, 'Quick Changes', @())) $lines.Add($r.HRule()) # Cuerpo según modo if ($this.Mode -eq 'select-branch') { $this.AppendSelectorBody($lines, $r, $reset) } else { $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 if ($this.Mode -eq 'input-message') { $cursor = $this.Theme.Fg('acc') + '_' + $reset $label = $this.Theme.Fg('fg2') + 'Mensaje del commit: ' + $reset $buf = $this.Theme.Fg('fg1') + $this.InputBuffer + $reset $lines.Add(' ' + $label + $buf + $cursor) } elseif ($this.StatusMessage) { $color = if ($this.StatusKind -eq 'error') { 'gitConflict' } else { 'gitClean' } $lines.Add(' ' + $this.Theme.Fg($color) + $this.StatusMessage + $reset) } else { $lines.Add('') } # State summary $summaryParts = @() $summaryParts += $this.Theme.Fg('fg2') + 'branch:' + $reset + ' ' + $this.Theme.Fg('acc') + $this.CurrentBranch + $reset if ($this.HasChanges) { $summaryParts += $this.Theme.Fg('gitDirty') + 'dirty' + $reset } else { $summaryParts += $this.Theme.Fg('gitClean') + 'clean' + $reset } if ($this.Ahead -gt 0) { $summaryParts += $this.Theme.Fg('gitAhead') + ('▲' + $this.Ahead) + $reset } if ($this.Behind -gt 0) { $summaryParts += $this.Theme.Fg('gitBehind') + ('▼' + $this.Behind) + $reset } if (-not $this.HasRemote) { $summaryParts += $this.Theme.Fg('fg3') + '(no remote)' + $reset } $lines.Add(' ' + ($summaryParts -join ' · ')) $lines.Add($r.HRule()) # Action items $items = $this.AvailableActions() for ($i = 0; $i -lt $items.Count; $i++) { $sel = ($this.SelectedIndex -eq $i -and $this.Mode -eq 'list') $marker = if ($sel) { $this.Theme.Fg('acc') + '▎ ▶ ' + $reset } else { ' · ' } $color = switch ($items[$i].Action) { 'push-no-verify' { 'gitDirty' } # naranja para destacar el flag peligroso 'cancel' { 'fg2' } default { if ($sel) { 'acc' } else { 'fg1' } } } $lines.Add($marker + $this.Theme.Fg($color) + $items[$i].Label + $reset) } } hidden [void] AppendSelectorBody([object]$lines, [object]$r, [string]$reset) { # Delega al FilteredListPicker componente reusable. $maxVisible = [Math]::Max(5, [Console]::WindowHeight - 12) $body = $this.BranchPicker.BuildBody($this.Theme, $r, $maxVisible) foreach ($l in $body) { $lines.Add($l) } } 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-branch' { return @( @{ k = '↑↓'; label = $this.T('hint.move') } @{ k = $this.T('hint.type'); label = $this.T('hint.search') } @{ k = '↵'; label = 'Switch' } @{ k = 'Esc'; label = $this.T('hint.cancel') } ) } 'input-message' { return @( @{ k = $this.T('hint.type'); label = $this.T('hint.save') } @{ k = '↵'; label = 'Commit' } @{ k = 'Esc'; label = $this.T('hint.cancel') } ) } default { return @( @{ k = '↑↓'; label = $this.T('hint.move') } @{ k = '↵'; label = $this.T('hint.run') } @{ k = '←'; label = $this.T('hint.back') } @{ k = 'Q'; label = $this.T('hint.quit') } ) } } return @() } } |