src/UI/Screens/BranchManagerScreen.ps1
|
# BranchManagerScreen — Vista de branches del repo seleccionado. # # State machine de modos: # list — navegando lista (modo default). # filter — '/' arranca buffer de filtro inline. Lista filtra por substring CI. # input — 'n' arranca prompt para crear branch. # confirm — destructivos (delete local/remote, merge) piden y/N inline. # # Patrón: BuildLines arma frame como array de líneas (testeable sin tty); Open # corre el loop con readkey + render a stderr. Cursor + alt buffer son # responsabilidad del root loop (MainScreen) — esta sub-screen no los toca. # # Bindings (modo list): # ↑↓ / jk navegar (con wrap) # g / G home / end # ↵ / s switch a la branch (noop si current) # r / R reload # / entrar en modo filter # n crear branch (modo input) # p pull (--ff-only en current) # P push (--set-upstream auto si falta) # d borrar local (con confirm) # D borrar remote (con confirm) # m merge selección en current (con confirm) # ←/h/Esc back al MainScreen # Q quit class BranchManagerScreen { [object] $Theme [object] $Renderer [object] $Primitives [object] $Frame [object] $AppHeader [object] $StatusBar [object] $Git [object] $I18n = $null # Inyectable post-construct. [object] $Repo [object[]] $AllBranches = @() # Resultado completo de Git.GetBranches. [int] $SelectedIndex = 0 [object] $Viewport = [Viewport]::new() [bool] $QuitRequested = $false [bool] $UseAltBuffer = $true # Filtro persistente (sobrevive entre acciones). Vacío = sin filtro. [string] $Filter = '' # Modo activo. Cada modo gobierna cómo se interpreta la próxima tecla. [string] $Mode = 'list' # 'list' | 'filter' | 'input' | 'confirm' | 'header' # Barra de chips arriba (launchers de flows v2). Ver $ChipNames para el orden. [int] $ActiveChip = 0 # 0..5 static [string[]] $ChipNames = @('Integrate', 'Quick', 'Release', 'Compare', 'Cherry-Pick', 'Graph') # Buffer mientras el user tipea (modo filter o input). [string] $InputBuffer = '' [string] $InputLabel = '' # Prompt visible en modo input/filter. # Acción pendiente que se ejecuta si el user confirma o termina input. # Hashtable @{ Kind='create'|'delete'|'deleteRemote'|'merge'; Branch=$obj } [hashtable] $Pending = $null # Status del último resultado (vacío al cambiar de modo o en nueva acción). [string] $StatusMessage = '' [string] $StatusKind = '' # 'ok' | 'error' | '' BranchManagerScreen($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 "BranchManagerScreen.Open: repo no puede ser null." } $this.Repo = $repo $this.SelectedIndex = 0 $this.Viewport.Reset() $this.StatusMessage = '' $this.StatusKind = '' $this.Filter = '' $this.Mode = 'list' $this.ActiveChip = 0 $this.InputBuffer = '' $this.Pending = $null # Branches vacío al inicio — se cargan después del primer frame para que la # transición desde MainScreen sea instantánea. $this.AllBranches = @() if ([Console]::IsInputRedirected) { $this.LoadBranches() $lines = $this.BuildLines('1.0.0') foreach ($l in $lines) { Write-Host $l } return } # Pre-frame INSTANTÁNEO que pisa el header del MainScreen antes de hacer # el git call. Sin esto, el header de la vista anterior queda visible # mientras LoadBranches corre (git for-each-ref puede tomar 50-200ms en # repos con muchas branches) — Tino lo reportó como parpadeo. $errOut = [Console]::Error $this.StatusMessage = 'cargando branches…' $this.StatusKind = 'ok' $earlyFrame = [AnsiService]::BeginSync() + [AnsiService]::MoveTo(1, 1) + (($this.BuildLines('1.0.0')) -join "`e[K`n") + "`e[K`e[J" + [AnsiService]::EndSync() $errOut.Write($earlyFrame) $errOut.Flush() # Ahora sí cargamos las branches reales. $this.LoadBranches() $this.StatusMessage = '' $this.StatusKind = '' try { while ($true) { $this.EnsureViewport() $lines = $this.BuildLines('1.0.0') $framePayload = [AnsiService]::BeginSync() + [AnsiService]::MoveTo(1, 1) + ($lines -join "`e[K`n") + "`e[K`e[J" + [AnsiService]::EndSync() $errOut.Write($framePayload) $key = [Console]::ReadKey($true) $exit = $this.HandleKey($key) if ($exit -eq 'back') { return } if ($exit -eq 'quit') { $this.QuitRequested = $true; return } } } finally { # Sub-screen: ni cursor ni alt buffer — eso es del root loop. } } # Maneja una tecla según el modo activo. # Retorna 'back', 'quit', o '' para seguir en el loop. hidden [string] HandleKey([System.ConsoleKeyInfo]$key) { switch ($this.Mode) { 'list' { return $this.HandleKeyList($key) } 'header' { return $this.HandleKeyHeader($key) } 'filter' { return $this.HandleKeyFilter($key) } 'input' { return $this.HandleKeyInput($key) } 'confirm' { return $this.HandleKeyConfirm($key) } } return '' } hidden [string] HandleKeyList([System.ConsoleKeyInfo]$key) { $k = $key.Key $c = $key.KeyChar $branches = $this.FilteredBranches() if ($c -eq 'q' -or $c -eq 'Q') { return 'quit' } if ($k -eq 'LeftArrow' -or $c -eq 'h' -or $k -eq 'Escape') { # Si hay filtro activo, primer Esc lo limpia antes de salir. if ($this.Filter) { $this.Filter = '' $this.SelectedIndex = 0 return '' } return 'back' } # Navegación if ($k -eq 'UpArrow' -or $c -eq 'k') { if ($branches.Count -gt 0) { $this.SelectedIndex = if ($this.SelectedIndex -le 0) { $branches.Count - 1 } else { $this.SelectedIndex - 1 } } return '' } if ($k -eq 'DownArrow' -or $c -eq 'j') { if ($branches.Count -gt 0) { $this.SelectedIndex = if ($this.SelectedIndex -ge $branches.Count - 1) { 0 } else { $this.SelectedIndex + 1 } } return '' } if ($k -eq 'Home' -or $c -eq 'g') { $this.SelectedIndex = 0; return '' } if ($k -eq 'End' -or $c -eq 'G') { $this.SelectedIndex = [Math]::Max(0, $branches.Count - 1); return '' } # Tab: pasar focus a la barra de chips. if ($k -eq 'Tab') { $this.Mode = 'header' return '' } # Filter if ($c -eq '/') { $this.Mode = 'filter' $this.InputBuffer = $this.Filter $this.InputLabel = 'Filtrar' return '' } # Switch if ($k -eq 'Enter' -or $c -eq 's') { $this.SwitchSelected(); return '' } # Reload if ($c -eq 'r' -or $c -eq 'R') { $this.LoadBranches() $this.SetStatus('lista actualizada', 'ok') return '' } # Crear branch (input) if ($c -eq 'n') { $this.Mode = 'input' $this.InputBuffer = '' $this.InputLabel = 'Nueva branch' $this.Pending = @{ Kind = 'create' } return '' } # Pull if ($c -eq 'p') { $this.DoPull(); return '' } # Push if ($c -eq 'P') { $this.DoPush($false); return '' } # Delete local (confirm) if ($c -eq 'd') { if ($branches.Count -eq 0) { return '' } $b = $branches[$this.SelectedIndex] if ($b.IsCurrent) { $this.SetStatus('no podés borrar la branch actual', 'error'); return '' } $this.Pending = @{ Kind = 'delete'; Branch = $b } $this.Mode = 'confirm' return '' } # Delete remote (confirm) if ($c -eq 'D') { if ($branches.Count -eq 0) { return '' } $b = $branches[$this.SelectedIndex] if (-not $b.Remote) { $this.SetStatus("'$($b.Name)' no tiene remote", 'error'); return '' } $this.Pending = @{ Kind = 'deleteRemote'; Branch = $b } $this.Mode = 'confirm' return '' } # Merge into current if ($c -eq 'm') { if ($branches.Count -eq 0) { return '' } $b = $branches[$this.SelectedIndex] if ($b.IsCurrent) { $this.SetStatus('no podés mergear la branch actual en sí misma', 'error'); return '' } $this.Pending = @{ Kind = 'merge'; Branch = $b } $this.Mode = 'confirm' return '' } return '' } # Modo Header (focus en la barra de chips arriba). ←→ navega chips, Enter # abre el flow correspondiente, ↑/Esc/Tab/k vuelve a list. hidden [string] HandleKeyHeader([System.ConsoleKeyInfo]$key) { $k = $key.Key $c = $key.KeyChar if ($c -eq 'q' -or $c -eq 'Q') { return 'quit' } if ($k -eq 'Tab' -or $k -eq 'Escape' -or $k -eq 'UpArrow' -or $c -eq 'k') { $this.Mode = 'list' return '' } if ($k -eq 'LeftArrow' -or $c -eq 'h') { $count = [BranchManagerScreen]::ChipNames.Count $this.ActiveChip = ($this.ActiveChip - 1 + $count) % $count return '' } if ($k -eq 'RightArrow' -or $c -eq 'l') { $count = [BranchManagerScreen]::ChipNames.Count $this.ActiveChip = ($this.ActiveChip + 1) % $count return '' } if ($k -eq 'Enter') { $this.LaunchActiveChip() return '' } return '' } # Lanza la screen del chip activo. Cada flow es una sub-screen completa con # su propio loop. Al volver, refrescamos branches por si cambiaron. hidden [void] LaunchActiveChip() { $name = [BranchManagerScreen]::ChipNames[$this.ActiveChip] $this.Mode = 'list' if ($name -eq 'Integrate') { $screen = [IntegrateScreen]::new( $this.Theme, $this.Renderer, $this.Primitives, $this.Frame, $this.AppHeader, $this.StatusBar, $this.Git) $screen.UseAltBuffer = $this.UseAltBuffer $screen.I18n = $this.I18n $screen.Open($this.Repo) if ($screen.QuitRequested) { $this.QuitRequested = $true; return } $this.LoadBranches() if ($screen.DoneMessage) { $kind = if ($screen.StatusKind) { $screen.StatusKind } else { 'ok' } $this.SetStatus($screen.DoneMessage, $kind) } return } if ($name -eq 'Quick') { $screen = [QuickChangeScreen]::new( $this.Theme, $this.Renderer, $this.Primitives, $this.Frame, $this.AppHeader, $this.StatusBar, $this.Git) $screen.UseAltBuffer = $this.UseAltBuffer $screen.I18n = $this.I18n $screen.Open($this.Repo) if ($screen.QuitRequested) { $this.QuitRequested = $true; return } $this.LoadBranches() if ($screen.StatusMessage) { $kind = if ($screen.StatusKind) { $screen.StatusKind } else { 'ok' } $this.SetStatus($screen.StatusMessage, $kind) } return } if ($name -eq 'Release') { $screen = [ReleaseScreen]::new( $this.Theme, $this.Renderer, $this.Primitives, $this.Frame, $this.AppHeader, $this.StatusBar, $this.Git) $screen.UseAltBuffer = $this.UseAltBuffer $screen.I18n = $this.I18n $screen.Open($this.Repo) if ($screen.QuitRequested) { $this.QuitRequested = $true; return } if ($screen.StatusMessage) { $kind = if ($screen.StatusKind) { $screen.StatusKind } else { 'ok' } $this.SetStatus($screen.StatusMessage, $kind) } return } if ($name -eq 'Graph') { $screen = [GraphScreen]::new( $this.Theme, $this.Renderer, $this.Primitives, $this.Frame, $this.AppHeader, $this.StatusBar, $this.Git) $screen.UseAltBuffer = $this.UseAltBuffer $screen.I18n = $this.I18n $screen.Open($this.Repo) if ($screen.QuitRequested) { $this.QuitRequested = $true; return } return } if ($name -eq 'Cherry-Pick') { $screen = [CherryPickScreen]::new( $this.Theme, $this.Renderer, $this.Primitives, $this.Frame, $this.AppHeader, $this.StatusBar, $this.Git) $screen.UseAltBuffer = $this.UseAltBuffer $screen.I18n = $this.I18n $screen.Open($this.Repo) if ($screen.QuitRequested) { $this.QuitRequested = $true; return } if ($screen.PickedCount -gt 0) { $this.LoadBranches() $this.SetStatus("$($screen.PickedCount) commit(s) cherry-pickeados", 'ok') } return } if ($name -eq 'Compare') { $screen = [CompareScreen]::new( $this.Theme, $this.Renderer, $this.Primitives, $this.Frame, $this.AppHeader, $this.StatusBar, $this.Git) $screen.UseAltBuffer = $this.UseAltBuffer $screen.I18n = $this.I18n $screen.Open($this.Repo) if ($screen.QuitRequested) { $this.QuitRequested = $true; return } return } # Todos los chips wired. Si llega acá, programmer error. $this.SetStatus("chip '$name' sin handler — bug en LaunchActiveChip", 'error') } hidden [string] HandleKeyFilter([System.ConsoleKeyInfo]$key) { $k = $key.Key $c = $key.KeyChar if ($k -eq 'Escape') { # Cancelar: limpia el filtro y vuelve. $this.Filter = '' $this.InputBuffer = '' $this.Mode = 'list' $this.SelectedIndex = 0 return '' } if ($k -eq 'Enter') { $this.Filter = $this.InputBuffer $this.InputBuffer = '' $this.Mode = 'list' $this.SelectedIndex = 0 return '' } if ($k -eq 'Backspace') { if ($this.InputBuffer.Length -gt 0) { $this.InputBuffer = $this.InputBuffer.Substring(0, $this.InputBuffer.Length - 1) } # Filtro live mientras tipea $this.Filter = $this.InputBuffer $this.SelectedIndex = 0 return '' } # Char printable → append + filter live if (-not [char]::IsControl($c)) { $this.InputBuffer += $c $this.Filter = $this.InputBuffer $this.SelectedIndex = 0 } return '' } hidden [string] HandleKeyInput([System.ConsoleKeyInfo]$key) { $k = $key.Key $c = $key.KeyChar if ($k -eq 'Escape') { $this.Mode = 'list' $this.InputBuffer = '' $this.Pending = $null return '' } if ($k -eq 'Enter') { $this.RunPendingFromInput() 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] HandleKeyConfirm([System.ConsoleKeyInfo]$key) { $c = $key.KeyChar if ($c -eq 'y' -or $c -eq 'Y') { $this.RunPendingDestructive() } else { # Cualquier otra tecla cancela (incluido Esc, n, N, espacio). $this.Mode = 'list' $this.Pending = $null } return '' } hidden [void] RunPendingFromInput() { if (-not $this.Pending) { $this.Mode = 'list'; return } $kind = $this.Pending.Kind $name = $this.InputBuffer.Trim() if ($kind -eq 'create') { if (-not $name) { $this.SetStatus('nombre vacío — cancelado', 'error') $this.Mode = 'list' $this.Pending = $null return } $r = $this.Git.CreateBranch($this.Repo.Path, $name, $true) $this.SetStatus($r.Message, ($(if ($r.Ok) { 'ok' } else { 'error' }))) if ($r.Ok) { $this.LoadBranches() } } $this.Mode = 'list' $this.InputBuffer = '' $this.Pending = $null } hidden [void] RunPendingDestructive() { if (-not $this.Pending) { $this.Mode = 'list'; return } $kind = $this.Pending.Kind $b = $this.Pending.Branch $r = $null switch ($kind) { 'delete' { $r = $this.Git.DeleteLocalBranch($this.Repo.Path, $b.Name, $false) } 'deleteRemote' { $r = $this.Git.DeleteRemoteBranch($this.Repo.Path, $b.Name, $b.Remote) } 'merge' { $r = $this.Git.MergeBranch($this.Repo.Path, $b.Name) } } if ($r) { $this.SetStatus($r.Message, ($(if ($r.Ok) { 'ok' } else { 'error' }))) if ($r.Ok) { $this.LoadBranches() } } $this.Mode = 'list' $this.Pending = $null } hidden [void] DoPull() { $r = $this.Git.Pull($this.Repo.Path) $this.SetStatus($r.Message, ($(if ($r.Ok) { 'ok' } else { 'error' }))) if ($r.Ok) { $this.LoadBranches() } } 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.LoadBranches() } } # Re-render inmediato del frame con el status actualizado, para que el user # vea el progreso del flow entre pasos. Sin esto, los handlers son # sincrónicos: el Write del frame solo ocurre al final del while iteration, # entonces el user solo ve el resultado final, no los pasos intermedios. hidden [void] FlashStatus([string]$msg, [string]$kind) { $this.SetStatus($msg, $kind) if ([Console]::IsInputRedirected) { return } $errOut = [Console]::Error $lines = $this.BuildLines('1.0.0') $framePayload = [AnsiService]::BeginSync() + [AnsiService]::MoveTo(1, 1) + ($lines -join "`e[K`n") + "`e[K`e[J" + [AnsiService]::EndSync() $errOut.Write($framePayload) $errOut.Flush() } hidden [void] SwitchSelected() { $branches = $this.FilteredBranches() if ($branches.Count -eq 0) { return } $target = $branches[$this.SelectedIndex] if ($target.IsCurrent) { $this.SetStatus("ya estás en $($target.Name)", 'ok'); return } $r = $this.Git.CheckoutBranch($this.Repo.Path, $target.Name) $this.SetStatus($r.Message, ($(if ($r.Ok) { 'ok' } else { 'error' }))) if ($r.Ok) { $this.LoadBranches() } } hidden [void] SetStatus([string]$msg, [string]$kind) { $this.StatusMessage = $msg $this.StatusKind = $kind } hidden [void] LoadBranches() { if ($null -eq $this.Repo -or -not $this.Repo.Path) { $this.AllBranches = @() return } $this.AllBranches = $this.Git.GetBranches($this.Repo.Path) # Clamp del SelectedIndex contra la lista filtrada. $count = $this.FilteredBranches().Count if ($this.SelectedIndex -ge $count) { $this.SelectedIndex = [Math]::Max(0, $count - 1) } } # Devuelve la lista filtrada por $this.Filter (substring CI sobre Name). # Si filter vacío, devuelve AllBranches. [object[]] FilteredBranches() { if (-not $this.Filter) { return $this.AllBranches } $needle = $this.Filter.ToLowerInvariant() return @($this.AllBranches | Where-Object { $_.Name.ToLowerInvariant().Contains($needle) }) } hidden [void] EnsureViewport() { $count = $this.FilteredBranches().Count if ($count -eq 0) { $this.Viewport.Reset(); return } # Chrome: # 1 titlebar + 1 appheader + 1 hr + 1 chips bar + 1 hr + 1 prompt/status # + 1 section title + 1 hr + 1 hr + 1 statusbar = 10 (sumando el hr interno) # En la práctica ajustado a 9 que era 7 antes + 2 por la chips bar y su hr. # +1 a chrome porque StatusBar ahora son 2 líneas (hints + counts/right). $available = [Console]::WindowHeight - 10 $this.Viewport.Resize($available, 1) $this.Viewport.EnsureVisible($this.SelectedIndex, $count) } [string[]] BuildLines([string]$version) { $reset = [AnsiService]::Reset $r = $this.Renderer $branches = $this.FilteredBranches() $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', "Branches · $repoName", $version)) # 2. AppHeader: breadcrumb del path + 'Branches' como current. $bc = [BreadcrumbBuilder]::Build($this.Repo.Path) $segs = @($bc.Segs) + @($bc.Current) $lines.Add($this.AppHeader.Render($segs, 'Branches', @())) $lines.Add($r.HRule()) # 2b. Barra de chips (launchers de flows v2). Siempre visible. El chip # activo se destaca como pill cuando focus=Header; en focus=List todos # se ven dim para indicar que no son interactivos en ese modo. $lines.Add($this.RenderChipsBar()) $lines.Add($r.HRule()) # 3. Línea de prompt o status (altura fija para que la lista no salte). $lines.Add($this.RenderPromptOrStatus()) # 4. Section title — agrega filter info y conteos. $titleText = ' Branches · ' + $branches.Count if ($this.Filter -and $branches.Count -ne $this.AllBranches.Count) { $titleText += ' de ' + $this.AllBranches.Count + " · /$($this.Filter)/" } elseif ($this.Filter) { $titleText += " · /$($this.Filter)/" } $lines.Add($this.Theme.Fg('fg2') + $titleText + $reset) $lines.Add($r.HRule()) # 5. Filas if ($null -ne $this.Viewport -and $this.Viewport.Capacity -gt 0 -and -not [Console]::IsInputRedirected) { $sliceStart = $this.Viewport.Start $sliceEnd = $this.Viewport.EndExclusive($branches.Count) } else { $sliceStart = 0 $sliceEnd = $branches.Count } if ($branches.Count -eq 0) { $msg = if ($this.Filter) { " (sin matches para /$($this.Filter)/)" } else { ' (no hay branches)' } $lines.Add($this.Theme.Fg('fg3') + $msg + $reset) } else { for ($i = $sliceStart; $i -lt $sliceEnd; $i++) { $b = $branches[$i] $lines.Add($this.RenderBranchRow($b, ($i -eq $this.SelectedIndex))) } } # 6. StatusBar — hints según el modo. $hints = $this.StatusBarHints() $lines.Add($r.HRule()) return @($lines.ToArray()) + $this.StatusBar.RenderAnchored($hints, '', $lines.Count) } # Línea entre el header y la sección de title. Muestra prompt activo, # confirmación pendiente, status del último resultado, o vacío. # Render de la barra de chips. focus=Header destaca el activo como pill; # focus=List atenua todos a fg3 (visualmente "no interactivos ahora"). hidden [string] RenderChipsBar() { $reset = [AnsiService]::Reset $names = [BranchManagerScreen]::ChipNames $isHeaderFocus = ($this.Mode -eq 'header') $sepFg = if ($isHeaderFocus) { $this.Theme.Fg('fg3') } else { $this.Theme.Fg('fg3') } $sep = $sepFg + ' · ' + $reset $parts = @() for ($i = 0; $i -lt $names.Count; $i++) { $name = $names[$i] if ($isHeaderFocus -and $i -eq $this.ActiveChip) { $bg = $this.Theme.Bg('acc') $fg = $this.Theme.Fg('bg0') $parts += "${bg}${fg} $name ${reset}" } elseif ($isHeaderFocus) { $parts += $this.Theme.Fg('fg1') + $name + $reset } else { $parts += $this.Theme.Fg('fg3') + $name + $reset } } return ' ' + ($parts -join $sep) } hidden [string] RenderPromptOrStatus() { $reset = [AnsiService]::Reset if ($this.Mode -eq 'filter' -or $this.Mode -eq 'input') { # Prompt: 'Label: |buffer_' $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.Mode -eq 'confirm' -and $this.Pending) { $kind = $this.Pending.Kind $b = $this.Pending.Branch $prompt = switch ($kind) { 'delete' { "Borrar local '$($b.Name)'? (y/N)" } 'deleteRemote' { "Borrar del remote '$($b.Remote)/$($b.Name)'? (y/N)" } 'merge' { "Mergear '$($b.Name)' en current? (y/N)" } default { '?' } } return ' ' + $this.Theme.Fg('gitDirty') + $prompt + $reset } if ($this.StatusMessage) { $color = if ($this.StatusKind -eq 'error') { 'gitConflict' } else { 'gitClean' } return ' ' + $this.Theme.Fg($color) + $this.StatusMessage + $reset } return '' } # Helper i18n con fallback graceful (mismo patrón que MainScreen.T). 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) { 'header' { return @( @{ k = '←→'; label = $this.T('hint.tab') } @{ k = '↵'; label = $this.T('hint.open') } @{ k = '↑'; label = $this.T('hint.back') } @{ k = 'Esc'; label = $this.T('hint.back') } @{ k = 'Q'; label = $this.T('hint.quit') } ) } 'filter' { return @( @{ k = $this.T('hint.type'); label = $this.T('hint.search') } @{ k = '↵'; label = $this.T('hint.accept') } @{ k = 'Esc'; label = $this.T('hint.cancel') } ) } 'input' { return @( @{ k = $this.T('hint.type'); label = $this.T('hint.save') } @{ k = '↵'; label = $this.T('hint.accept') } @{ k = 'Esc'; label = $this.T('hint.cancel') } ) } 'confirm' { return @( @{ k = 'Y'; label = $this.T('common.yes') } @{ k = '*'; label = $this.T('common.no') } ) } default { # 'list' — bindings completos. return @( @{ k = '↑↓'; label = $this.T('hint.move') } @{ k = '↵'; label = 'Switch' } @{ k = 'Tab';label = 'Flows ↑' } @{ k = '/'; label = $this.T('hint.search') } @{ k = 'n'; label = 'New' } @{ k = 'p'; label = 'Pull' } @{ k = 'P'; label = 'Push' } @{ k = 'd'; label = 'Del' } @{ k = 'D'; label = 'DelRm' } @{ k = 'm'; label = 'Merge' } @{ k = 'R'; label = $this.T('hint.reload') } @{ k = '←'; label = $this.T('hint.back') } @{ k = 'Q'; label = $this.T('hint.quit') } ) } } return @() } hidden [string] RenderBranchRow([object]$b, [bool]$selected) { $reset = [AnsiService]::Reset $marker = if ($selected) { $this.Theme.Fg('acc') + '▎' + $reset } else { ' ' } $dot = if ($b.IsCurrent) { $this.Theme.Fg('acc') + '●' + $reset } else { $this.Theme.Fg('fg3') + '·' + $reset } $nameColor = if ($b.IsCurrent) { 'acc' } elseif ($b.IsProtected) { 'fg2' } else { 'fg1' } $name = $this.Theme.Fg($nameColor) + $b.Name + $reset $tracking = if (-not $b.Remote) { $this.Theme.Fg('fg3') + '(local)' + $reset } else { $aheadStr = if ($b.Ahead -gt 0) { $this.Theme.Fg('gitAhead') + '▲' + $b.Ahead + $reset } else { '' } $behindStr = if ($b.Behind -gt 0) { $this.Theme.Fg('gitBehind') + '▼' + $b.Behind + $reset } else { '' } $sep = if ($aheadStr -and $behindStr) { ' ' } else { '' } if ($aheadStr -or $behindStr) { ($aheadStr + $sep + $behindStr) } else { $this.Theme.Fg('fg3') + '·' + $reset } } $meta = '' if ($b.LastCommitAuthor -or $b.LastCommitDate) { $author = if ($b.LastCommitAuthor) { $b.LastCommitAuthor } else { '' } $date = if ($b.LastCommitDate) { $b.LastCommitDate } else { '' } $sep = if ($author -and $date) { ' · ' } else { '' } $meta = $this.Theme.Fg('fg3') + ($author + $sep + $date) + $reset } $proto = if ($b.IsProtected) { ' ' + $this.Primitives.Pill('protected', 'fg2') } else { '' } return "$marker $dot $name $tracking $meta$proto" } } |