src/UI/Screens/MainScreen.ps1
|
# MainScreen — Vista 1 del diseño (new-design/repo-nav/js/screens/main.jsx). # MVP: render ESTÁTICO sin input loop ni alt-buffer. # - Title bar # - AppHeader con breadcrumb # - Lista de repos en secciones (Favorites / All) — sin filtros todavía # - StatusBar al fondo class MainScreen { [object] $Theme [object] $Renderer [object] $Primitives [object] $Frame [object] $AppHeader [object] $StatusBar [object] $Git [object] $SettingsSvc # Inyectable. Si null, favoritos no persisten (graceful fallback). [object] $Settings = $null # State cargado al arrancar el loop. [object] $I18n = $null # Inyectable post-construct (Startup la setea). Si null, # los textos quedan en español default — ver T() helper abajo. [int] $SelectedIndex = 0 [object] $Viewport = [Viewport]::new() [object[]] $Repos = @() [string] $CurrentPath = '' [object] $NavStack = $null # NavStack o $null. Null = modo static (Render no-tty). [string] $Focus = 'List' # 'List' | 'Header' | 'Filter' | 'Breadcrumb' [int] $ActiveTab = 2 # 0=Browse, 1=Search, 2=Git (Prefs salió del menú — se abre con 'U' directo desde la lista) [bool] $QuitRequested = $false [bool] $UseAltBuffer = $true # Filter inline (substring CI sobre Repo.Name). Vacío = sin filtro. [string] $Filter = '' [string] $InputBuffer = '' # Buffer mientras Focus='Filter'. # Indice del segmento del breadcrumb cuando Focus='Breadcrumb'. -1 sin focus. [int] $BreadcrumbIndex = -1 MainScreen($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.SettingsSvc = $null } # Overload para inyectar SettingsService. Mantengo el constructor de 7 args # compatible con call-sites antiguos (ej: tests con stubs). Cuando hay svc, # los favoritos persisten; sin svc, son no-op. MainScreen($theme, $renderer, $primitives, $frame, $appHeader, $statusBar, $git, $settingsSvc) { $this.Theme = $theme $this.Renderer = $renderer $this.Primitives = $primitives $this.Frame = $frame $this.AppHeader = $appHeader $this.StatusBar = $statusBar $this.Git = $git $this.SettingsSvc = $settingsSvc } # Loop interactivo con ↑↓/jk navegación, ↵ selecciona, q/Esc sale. # Devuelve el Repo seleccionado o $null si se canceló. # Si stdin está redirected (sin TTY), hace render estático y vuelve. [object] RunInteractive([string]$rootPath, [object[]]$repos, [string]$version) { $this.Repos = $repos $this.CurrentPath = $rootPath # Cargar settings al arrancar para que favoritos sean visibles desde el # primer frame. Si no hay svc, $this.Settings queda en null y todo lo de # favoritos es no-op (graceful). if ($this.SettingsSvc) { $this.Settings = $this.SettingsSvc.Load() } if ([Console]::IsInputRedirected) { $this.Render($version) return $null } $this.SelectedIndex = 0 $this.Viewport.Reset() $this.NavStack = [NavStack]::new() $this.Focus = 'List' $this.ActiveTab = 2 $this.QuitRequested = $false $this.Filter = '' $this.InputBuffer = '' # Las secuencias de control van a stderr para no contaminar stdout # (así `cd $(./repo-nav.ps1)` queda capturando sólo el path final). $errOut = [Console]::Error if ($this.UseAltBuffer) { $errOut.Write([AnsiService]::EnterAltBuffer()) } $errOut.Write([AnsiService]::HideCursor()) $result = $null # Primer frame: clear total para arrancar limpio. $errOut.Write([AnsiService]::ClearScreen()) # Loop no-bloqueante: KeyAvailable + Sleep 50ms cede CPU; ~0% en idle. # Solo re-renderea cuando hay tecla, terminó un job de fetch, o cambio # de estado. $needRender = $true # primer frame siempre se dibuja # Background fetch lifecycle. Settings.BackgroundFetchInterval > 0 activa # un job periódico que hace 'git fetch --quiet' paralelo en los repos # visibles. Resultado: ahead/behind queda fresco sin que el user toque # nada y sin bloquear la UI. Ver pieza B del plan UX-redlenta. $bgFetchInterval = if ($this.Settings -and $this.Settings.BackgroundFetchInterval -gt 0) { [int]$this.Settings.BackgroundFetchInterval } else { 0 } $bgFetchJob = $null # Primer fetch: 30s después del boot — no apenas arranca (queremos ver # la TUI primero) ni lo más tarde posible. Después, cada $bgFetchInterval s. $nextBgFetchAt = if ($bgFetchInterval -gt 0) { [DateTime]::UtcNow.AddSeconds(30) } else { [DateTime]::MaxValue } try { while ($true) { # Re-render solo si algo cambió (key, tick de spinner, etc.). if ($needRender) { $this.EnsureViewport() $lines = $this.BuildLines($version) $framePayload = [AnsiService]::BeginSync() + [AnsiService]::MoveTo(1, 1) + ($lines -join "`e[K`n") + "`e[K`e[J" + [AnsiService]::EndSync() $errOut.Write($framePayload) $needRender = $false } # Polling no-bloqueante: si no hay tecla, dormimos 50ms y # chequeamos el background fetch periódico. if (-not [Console]::KeyAvailable) { $now = [DateTime]::UtcNow # Background fetch — kick off si toca y no hay job en curso. if ($bgFetchInterval -gt 0 -and $null -eq $bgFetchJob -and $now -ge $nextBgFetchAt) { $bgFetchJob = $this.StartBackgroundFetch() } # Drain del job si terminó: aplicar updates a $this.Repos. if ($null -ne $bgFetchJob -and $bgFetchJob.State -in @('Completed', 'Failed', 'Stopped')) { $output = Receive-Job $bgFetchJob -ErrorAction SilentlyContinue Remove-Job $bgFetchJob -Force -ErrorAction SilentlyContinue $bgFetchJob = $null $nextBgFetchAt = $now.AddSeconds($bgFetchInterval) if ($output) { $this.ApplyBackgroundFetchResults($output) $needRender = $true } } Start-Sleep -Milliseconds 50 continue } $key = [Console]::ReadKey($true) $k = $key.Key $c = $key.KeyChar $needRender = $true # Quit propagado desde un stub. if ($this.QuitRequested) { break } # Quit es global (funciona en cualquier focus). if ($c -eq 'q' -or $c -eq 'Q') { break } # PS class methods son strict: las locales hay que declararlas en # un path que TODOS los branches puedan ver. Visible/count se usan # en handlers de varios modes (List, AliasInput, AliasConfirmRemove). $visible = $this.FilteredRepos() $count = $visible.Count if ($this.Focus -eq 'List') { if ($k -eq 'UpArrow' -or $c -eq 'k') { # Wrap-around: del primero saltás al último (paridad con v2). if ($count -gt 0) { if ($this.SelectedIndex -le 0) { $this.SelectedIndex = $count - 1 } else { $this.SelectedIndex-- } } } elseif ($k -eq 'DownArrow' -or $c -eq 'j') { if ($count -gt 0) { if ($this.SelectedIndex -ge $count - 1) { $this.SelectedIndex = 0 } else { $this.SelectedIndex++ } } } elseif ($k -eq 'Home' -or $c -eq 'g') { $this.SelectedIndex = 0 } elseif ($k -eq 'End' -or $c -eq 'G') { $this.SelectedIndex = [Math]::Max(0, $count - 1) } elseif ($k -eq 'PageUp') { $this.SelectedIndex = [Math]::Max(0, $this.SelectedIndex - 10) } elseif ($k -eq 'PageDown') { $this.SelectedIndex = [Math]::Min($count - 1, $this.SelectedIndex + 10) } elseif ($c -eq '/' -or $c -eq 's' -or $c -eq 'S') { # Filter inline. Ambos triggers funcionan: '/' (vim/lazygit) # y 'S' (paridad con SearchCommand del v2). $this.Focus = 'Filter' $this.InputBuffer = $this.Filter } elseif ($k -eq 'Tab') { # Tab → focus al breadcrumb arriba (migas de pan navegables # estilo VS Code / browser). En el breadcrumb: ←→ navega # segmentos, Enter drill al path elegido. $bcInfo = [BreadcrumbBuilder]::BuildWithPaths($this.CurrentPath) if ($bcInfo.AbsolutePaths.Count -gt 0) { $this.Focus = 'Breadcrumb' # Arrancar en el segmento current (último). $this.BreadcrumbIndex = $bcInfo.AbsolutePaths.Count - 1 } } elseif ($k -eq 'Spacebar') { # Atajo Space → BrowseScreen (paridad con BrowseCommand del v2). $browser = [BrowseScreen]::new( $this.Theme, $this.Renderer, $this.Primitives, $this.Frame, $this.AppHeader, $this.StatusBar) $browser.UseAltBuffer = $this.UseAltBuffer $browser.I18n = $this.I18n $browser.Open($this.CurrentPath) if ($browser.QuitRequested) { $this.QuitRequested = $true; break } $errOut.Write([AnsiService]::HideCursor()) if (-not $browser.Cancelled -and $browser.SelectedPath -and (Test-Path -LiteralPath $browser.SelectedPath)) { $this.DrillToPath($browser.SelectedPath) } } elseif ($k -eq 'RightArrow' -or $c -eq 'l') { # Drill-in: container → entrar; repo git → tab menu (focus=Header). if ($count -gt 0) { $cur = $visible[$this.SelectedIndex] if ($cur.IsContainer) { $this.DrillIn($cur) } elseif ($cur.Status -ne 'nogit') { $this.Focus = 'Header' } } } elseif ($k -eq 'LeftArrow' -or $c -eq 'h') { $this.DrillOut() } elseif ($k -eq 'Enter') { if ($count -gt 0) { $result = $visible[$this.SelectedIndex] } break } elseif ($k -eq 'Escape') { # Esc con filtro activo lo limpia primero; sino sale. if ($this.Filter) { $this.Filter = '' $this.SelectedIndex = 0 } else { break } } elseif ($c -eq 'r' -or $c -eq 'R') { # R: si hay repos unloaded (modo AutoLoad=Favorites/None), # los carga todos en bulk. Si no, hace refresh del actual. $this.Git.InvalidateCache() $unloadedCount = @($this.Repos | Where-Object { $_.Status -eq 'unloaded' }).Count if ($unloadedCount -gt 0) { $this.LoadAllUnloaded() } elseif ($count -gt 0) { $cur = $visible[$this.SelectedIndex] if (-not $cur.IsContainer) { $this.RefreshRepoInPlace($cur.Path) } } } elseif ($c -eq 'f' -or $c -eq 'F') { # Toggle favorito en el repo seleccionado. Persiste via SettingsSvc. if ($count -gt 0) { $cur = $visible[$this.SelectedIndex] if (-not $cur.IsContainer -and $cur.Status -ne 'nogit') { $this.ToggleFavorite($cur) } } } elseif ($c -eq 'a' -or $c -eq 'A') { # Set/edit alias del repo seleccionado. Aceptamos ambos # casos para consistencia con el resto de bindings (R/U/Q # también aceptan minus + may). if ($count -gt 0) { $cur = $visible[$this.SelectedIndex] if (-not $cur.IsContainer) { $existing = $this.GetAliasFor($cur) $this.InputBuffer = if ($existing) { [string]$existing.alias } else { '' } $this.Focus = 'AliasInput' } } } elseif ($c -eq 'u' -or $c -eq 'U') { # Atajo directo a Preferences — sale del tab menu para que # el user no tenga que → → → ↵. Si quit dentro de prefs, # propagamos break al loop principal. if ($this.OpenPreferences($errOut)) { break } } # Lazy-load: después de procesar la tecla (cualquiera), si el # repo bajo cursor sigue unloaded en modo Favorites, lo cargamos # (UX: pasar con ↑↓ va llenando datos automáticamente). # En modo None NO auto-cargamos — None significa "manual", # solo R o pulsar Enter sobre el repo activa la carga. # Va FUERA del elseif-chain — sino se acopla como rama y # rompe los handlers de teclas que no matchearon. if ($count -gt 0 -and $this.Focus -eq 'List') { $cur = $visible[$this.SelectedIndex] if ($cur -and $cur.Status -eq 'unloaded') { $autoMode = if ($this.Settings -and $this.Settings.AutoLoadMode) { $this.Settings.AutoLoadMode } else { 'All' } if ($autoMode -ne 'None') { $this.RefreshRepoInPlace($cur.Path) } } } } elseif ($this.Focus -eq 'Breadcrumb') { $bcInfo = [BreadcrumbBuilder]::BuildWithPaths($this.CurrentPath) $segCount = $bcInfo.AbsolutePaths.Count if ($c -eq 'q' -or $c -eq 'Q') { break } if ($k -eq 'Tab' -or $k -eq 'Escape' -or $k -eq 'DownArrow' -or $c -eq 'j') { $this.Focus = 'List' $this.BreadcrumbIndex = -1 } elseif ($k -eq 'LeftArrow' -or $c -eq 'h') { if ($segCount -gt 0) { $this.BreadcrumbIndex = if ($this.BreadcrumbIndex -le 0) { $segCount - 1 } else { $this.BreadcrumbIndex - 1 } } } elseif ($k -eq 'RightArrow' -or $c -eq 'l') { if ($segCount -gt 0) { $this.BreadcrumbIndex = if ($this.BreadcrumbIndex -ge $segCount - 1) { 0 } else { $this.BreadcrumbIndex + 1 } } } elseif ($k -eq 'Enter') { if ($this.BreadcrumbIndex -ge 0 -and $this.BreadcrumbIndex -lt $segCount) { $target = $bcInfo.AbsolutePaths[$this.BreadcrumbIndex] $this.DrillToPath($target) } $this.Focus = 'List' $this.BreadcrumbIndex = -1 } } elseif ($this.Focus -eq 'AliasInput') { if ($k -eq 'Escape') { $this.InputBuffer = '' $this.Focus = 'List' } elseif ($k -eq 'Enter') { $alias = $this.InputBuffer.Trim() $this.InputBuffer = '' if ($count -gt 0) { $cur = $visible[$this.SelectedIndex] if ($alias) { # Buffer con texto: set / edit del alias. $this.SetAliasFor($cur, $alias) $this.Focus = 'List' } elseif ($this.GetAliasFor($cur)) { # Buffer vacío + el repo tenía alias = el user borró # todo y le dio Enter. Pasamos al confirm de eliminación # — sin atajos secretos, una sola tecla 'a' cubre el flow. $this.Focus = 'AliasConfirmRemove' } else { # Buffer vacío y no había alias: simplemente cancelar. $this.Focus = 'List' } } else { $this.Focus = 'List' } } elseif ($k -eq 'Backspace') { if ($this.InputBuffer.Length -gt 0) { $this.InputBuffer = $this.InputBuffer.Substring(0, $this.InputBuffer.Length - 1) } } elseif (-not [char]::IsControl($c) -and $c -notmatch '\s') { # No permitimos espacios en el alias (igual que el v2). $this.InputBuffer += $c } } elseif ($this.Focus -eq 'AliasConfirmRemove') { if ($c -eq 'y' -or $c -eq 'Y') { if ($count -gt 0) { $cur = $visible[$this.SelectedIndex] $this.RemoveAliasFor($cur) } } $this.Focus = 'List' } elseif ($this.Focus -eq 'Filter') { if ($k -eq 'Escape') { $this.Filter = '' $this.InputBuffer = '' $this.Focus = 'List' $this.SelectedIndex = 0 } elseif ($k -eq 'Enter') { $this.Filter = $this.InputBuffer $this.InputBuffer = '' $this.Focus = 'List' $this.SelectedIndex = 0 } elseif ($k -eq 'Backspace') { if ($this.InputBuffer.Length -gt 0) { $this.InputBuffer = $this.InputBuffer.Substring(0, $this.InputBuffer.Length - 1) } $this.Filter = $this.InputBuffer $this.SelectedIndex = 0 } elseif (-not [char]::IsControl($c)) { $this.InputBuffer += $c $this.Filter = $this.InputBuffer $this.SelectedIndex = 0 } } else { # Focus = 'Header' — tab menu activo $tabCount = 3 # Browse / Search / Git — Prefs salió del menú (binding 'U' directo). if ($k -eq 'LeftArrow' -or $c -eq 'h') { $this.ActiveTab = ($this.ActiveTab - 1 + $tabCount) % $tabCount } elseif ($k -eq 'RightArrow' -or $c -eq 'l') { $this.ActiveTab = ($this.ActiveTab + 1) % $tabCount } elseif ($k -eq 'UpArrow' -or $k -eq 'Escape' -or $c -eq 'k') { # Cierra el menu, vuelve al list. NO toca SelectedIndex/ViewportStart. $this.Focus = 'List' } elseif ($k -eq 'Enter') { # Cuando vuelve, sigue en List. $tabLabels = @('Browse', 'Search', 'BranchManager') $tabLabel = $tabLabels[$this.ActiveTab] # BranchManager → screen real si hay repo git seleccionado. $visibleHdr = $this.FilteredRepos() if ($tabLabel -eq 'BranchManager' -and $visibleHdr.Count -gt 0) { $cur = $visibleHdr[$this.SelectedIndex] if (-not $cur.IsContainer -and $cur.Status -ne 'nogit') { $bms = [BranchManagerScreen]::new( $this.Theme, $this.Renderer, $this.Primitives, $this.Frame, $this.AppHeader, $this.StatusBar, $this.Git) $bms.UseAltBuffer = $this.UseAltBuffer $bms.I18n = $this.I18n $bms.Open($cur) if ($bms.QuitRequested) { $this.QuitRequested = $true; break } # Re-hide cursor defensivo. NO ClearScreen — el siguiente # frame del while ya limpia con MoveTo + ESC[J. Clear # explícito acá flashea pantalla negra entre las dos vistas. $errOut.Write([AnsiService]::HideCursor()) # Refresca el repo: el branch puede haber cambiado. $this.RefreshRepoInPlace($cur.Path) $this.Focus = 'List' continue } } # Browse → BrowseScreen real (filesystem navigator). if ($tabLabel -eq 'Browse') { $browser = [BrowseScreen]::new( $this.Theme, $this.Renderer, $this.Primitives, $this.Frame, $this.AppHeader, $this.StatusBar) $browser.UseAltBuffer = $this.UseAltBuffer $browser.Open($this.CurrentPath) if ($browser.QuitRequested) { $this.QuitRequested = $true; break } $errOut.Write([AnsiService]::HideCursor()) if (-not $browser.Cancelled -and $browser.SelectedPath -and (Test-Path -LiteralPath $browser.SelectedPath)) { $this.DrillToPath($browser.SelectedPath) } $this.Focus = 'List' continue } # Search → entra en modo Filter inline. No es una sub-screen # — el filter live sobre la lista actual es más útil. if ($tabLabel -eq 'Search') { $this.Focus = 'Filter' $this.InputBuffer = $this.Filter continue } # Browse (o BranchManager sin repo válido) → stub por ahora. $this.ShowStub($tabLabel) if ($this.QuitRequested) { break } $errOut.Write([AnsiService]::HideCursor()) $this.Focus = 'List' } } } } finally { # Cleanup del background fetch job — si quedó en curso al salir, # cancelar y removerlo para no dejar threads zombi. if ($null -ne $bgFetchJob) { Stop-Job $bgFetchJob -ErrorAction SilentlyContinue Remove-Job $bgFetchJob -Force -ErrorAction SilentlyContinue } $errOut.Write([AnsiService]::ShowCursor()) if ($this.UseAltBuffer) { $errOut.Write([AnsiService]::LeaveAltBuffer()) } } return $result } # Lanza el thread job de background fetch — corre git fetch en paralelo # contra todos los repos visibles con remote. Devuelve hashtables con # @{ Path; Ok; Hashtable de estado nuevo }. ApplyBackgroundFetchResults # los integra al $this.Repos[]. hidden [object] StartBackgroundFetch() { # Solo repos que tienen status (los unloaded se cargan vía lazy/R) y # que no son nogit/container. $candidates = @($this.Repos | Where-Object { $_.Status -in @('clean', 'dirty', 'unpushed', 'behind', 'conflict') } | ForEach-Object { $_.Path }) if ($candidates.Count -eq 0) { return $null } # Body de Get-RepoStateData como string — mismo patrón que DiscoverParallel. $fnBody = (Get-Command Get-RepoStateData -ErrorAction Stop).Definition return Start-ThreadJob -ScriptBlock { param($paths, $body) $sb = [scriptblock]::Create($body) foreach ($p in $paths) { # Fetch silencioso. Si falla (no hay red, no hay remote), seguimos. $prev = Get-Location try { Set-Location -LiteralPath $p -ErrorAction Stop & git fetch --quiet --all --prune 2>$null $exit = $LASTEXITCODE } catch { $exit = -1 } finally { Set-Location -LiteralPath $prev -ErrorAction SilentlyContinue } if ($exit -eq 0) { # Re-leer status para que ahead/behind quede fresco. $h = & $sb -Path $p if ($h) { Write-Output $h } } } } -ArgumentList @(, $candidates), $fnBody } # Integra los hashtables emitidos por el background fetch al $this.Repos[]. # Solo actualiza campos derivados de remote (Ahead, Behind, Status) — no # tocamos el LastCommit ni el Branch (esos no cambian con un fetch puro). # Y dispara OnFetchSuccess para persistir el LastFetchByRepo. hidden [void] ApplyBackgroundFetchResults([object[]]$hashtables) { if (-not $hashtables) { return } foreach ($h in $hashtables) { if ($null -eq $h -or -not $h.Path) { continue } for ($i = 0; $i -lt $this.Repos.Count; $i++) { if ($this.Repos[$i].Path -eq $h.Path) { $r = $this.Repos[$i] $r.Ahead = $h.Ahead $r.Behind = $h.Behind $r.Status = $h.Status break } } # Notificamos el "fetch sucedió" igual que un Pull/Push manual lo # haría — para que LastFetchByRepo se actualice. if ($null -ne $this.Git -and $null -ne $this.Git.OnFetchSuccess) { try { & $this.Git.OnFetchSuccess $h.Path } catch { $null = $_ } } } } # Render estático para fallback no-tty: imprime cada línea con Write-Host. # Asume que $this.Repos y $this.CurrentPath ya están seteados. [void] Render([string]$version) { $lines = $this.BuildLines($version) foreach ($line in $lines) { Write-Host $line } } # ¿El repo está marcado como favorito en los settings cargados? [bool] IsFavorite([object]$repo) { if ($null -eq $this.Settings -or $null -eq $repo) { return $false } if (-not $repo.Id) { return $false } return $this.Settings.IsFavorite($repo.Id) } # Devuelve el alias hashtable @{ alias=''; color='' } para el repo, o $null # si no tiene alias configurado. [hashtable] GetAliasFor([object]$repo) { if ($null -eq $this.Settings -or $null -eq $repo) { return $null } if (-not $repo.Id) { return $null } return $this.Settings.GetAlias($repo.Id) } # Set/edit alias. Color por default = primer item de la palette del theme. hidden [void] SetAliasFor([object]$repo, [string]$alias) { if ($null -eq $this.Settings -or $null -eq $this.SettingsSvc) { return } if ($null -eq $repo -or -not $repo.Id) { return } # Si ya tiene alias, conservamos su color. Sino tomamos el primer color # de la palette del theme actual. $existing = $this.Settings.GetAlias($repo.Id) $color = if ($existing -and $existing.color) { [string]$existing.color } else { $palette = @($this.Theme.GetActive().palette) if ($palette.Count -gt 0) { [string]$palette[0] } else { '#7ee787' } } $this.Settings.SetAlias($repo.Id, $alias, $color) $this.SettingsSvc.Save($this.Settings) | Out-Null } hidden [void] RemoveAliasFor([object]$repo) { if ($null -eq $this.Settings -or $null -eq $this.SettingsSvc) { return } if ($null -eq $repo -or -not $repo.Id) { return } $this.Settings.RemoveAlias($repo.Id) $this.SettingsSvc.Save($this.Settings) | Out-Null } # Formatea el name visible de un repo combinando: estrella si favorito, # alias en su color custom (con name original en gris a la derecha) si tiene # alias, sino name plano con prefix container si aplica. # Recibe $selectedFgAnsi para usarlo cuando NO hay alias (el alias siempre # se ve con su color custom, ignora highlight de selección). hidden [string] FormatRowName([object]$repo, [string]$selectedFgAnsi) { $reset = [AnsiService]::Reset $star = if ($this.IsFavorite($repo)) { $this.Theme.Fg('gitDirty') + '★ ' + $reset } else { '' } $aliasInfo = $this.GetAliasFor($repo) if ($aliasInfo -and $aliasInfo.alias) { $colorHex = if ($aliasInfo.color) { [string]$aliasInfo.color } else { '#7ee787' } $colorAnsi = [AnsiService]::FgHex($colorHex) $aliasText = "${colorAnsi}$([string]$aliasInfo.alias)${reset}" $nameSubtle = $this.Theme.Fg('fg3') + ' (' + $repo.Name + ')' + $reset return "${star}${aliasText}${nameSubtle}" } $base = if ($repo.IsContainer) { "▸ $($repo.Name)" } else { $repo.Name } return "${star}${selectedFgAnsi}${base}${reset}" } # Toggle del favorito + persistencia. Sin SettingsSvc, no-op. hidden [void] ToggleFavorite([object]$repo) { if ($null -eq $this.Settings -or $null -eq $this.SettingsSvc) { return } if ($null -eq $repo -or -not $repo.Id) { return } $id = $repo.Id $list = @($this.Settings.FavoriteIds) if ($list -contains $id) { $this.Settings.FavoriteIds = @($list | Where-Object { $_ -ne $id }) } else { $this.Settings.FavoriteIds = @($list + $id) } $this.SettingsSvc.Save($this.Settings) | Out-Null } # Helper i18n: traduce una key con fallback graceful si I18n no está cableado # (caso tests / construcción standalone). El fallback delega a I18nService.T # via instancia ad-hoc para mantener la lógica en un solo lugar. hidden [string] T([string]$key) { if ($null -ne $this.I18n) { return $this.I18n.T($key) } $fallback = [I18nService]::new('es') return $fallback.T($key) } hidden [string] T([string]$key, [object[]]$values) { if ($null -ne $this.I18n) { return $this.I18n.T($key, $values) } $fallback = [I18nService]::new('es') return $fallback.T($key, $values) } # Helpers de filter — la lista visible es FilteredRepos(); la base es $this.Repos. # Si Settings.FavoritesFirst está on, además sortea poniendo los favs arriba # (sin alterar el orden alfabético dentro de cada grupo — el discovery ya # devolvió todo ordenado, así que solo particionamos preservando el orden). [object[]] FilteredRepos() { $base = if ($this.Filter) { $needle = $this.Filter.ToLowerInvariant() @($this.Repos | Where-Object { $name = if ($_.Name) { $_.Name.ToLowerInvariant() } else { '' } $name.Contains($needle) }) } else { $this.Repos } if ($null -eq $this.Settings -or -not $this.Settings.FavoritesFirst) { return $base } $favs = @() $rest = @() foreach ($r in $base) { if ($null -ne $r.Id -and $this.Settings.IsFavorite($r.Id)) { $favs += $r } else { $rest += $r } } return @($favs + $rest) } # Abre la PreferencesScreen, recarga settings al volver y aplica transición de # AutoLoadMode si cambió. Devuelve $true si el user pidió quit dentro de prefs # (el caller debe propagar break del loop principal). Centraliza el flow para # que pueda llamarse desde el binding 'U' directo o desde el tab menu. [bool] OpenPreferences([object]$errOut) { if ($null -eq $this.SettingsSvc) { return $false } $prevMode = if ($this.Settings -and $this.Settings.AutoLoadMode) { $this.Settings.AutoLoadMode } else { 'All' } $prefs = [PreferencesScreen]::new( $this.Theme, $this.Renderer, $this.Primitives, $this.Frame, $this.AppHeader, $this.StatusBar, $this.SettingsSvc) $prefs.UseAltBuffer = $this.UseAltBuffer $prefs.I18n = $this.I18n $prefs.Open() if ($prefs.QuitRequested) { $this.QuitRequested = $true; return $true } if ($null -ne $errOut) { $errOut.Write([AnsiService]::HideCursor()) } # Recargar settings vivos y aplicar theme nuevo si cambió. $this.Settings = $this.SettingsSvc.Load() if ($this.Settings.ThemeKey) { try { $this.Theme.SetActive($this.Settings.ThemeKey) } catch { $null = $_ } } # Si el AutoLoadMode cambió, aplicar la transición sin pedir reinicio. # Solo cargamos más — nunca des-cargamos lo que ya está cargado. $newMode = if ($this.Settings.AutoLoadMode) { $this.Settings.AutoLoadMode } else { 'All' } if ($prevMode -ne $newMode) { if ($newMode -eq 'All') { $this.LoadAllUnloaded() } elseif ($newMode -eq 'Favorites' -and $this.Settings.FavoriteIds.Count -gt 0) { for ($i = 0; $i -lt $this.Repos.Count; $i++) { if ($this.Repos[$i].Status -eq 'unloaded' -and $this.Settings.FavoriteIds -contains $this.Repos[$i].Id) { $this.Repos[$i] = $this.Git.GetRepoState($this.Repos[$i].Path) } } } } $this.Focus = 'List' return $false } # Reemplaza un repo en $this.Repos por su versión recién leída de Git, buscando # por path. Sirve cuando estamos en un slice filtered y no podemos usar el index # del filtered como index del array total. [void] RefreshRepoInPlace([string]$path) { if (-not $path) { return } for ($i = 0; $i -lt $this.Repos.Count; $i++) { if ($this.Repos[$i].Path -eq $path) { $this.Repos[$i] = $this.Git.GetRepoState($path) return } } } # Convierte todos los repos con Status='unloaded' a estado completo (carga # git status para cada uno). Usado por el binding R cuando AutoLoadMode # arrancó en Favorites/None y el user quiere ver todo. [void] LoadAllUnloaded() { for ($i = 0; $i -lt $this.Repos.Count; $i++) { if ($this.Repos[$i].Status -eq 'unloaded') { $this.Repos[$i] = $this.Git.GetRepoState($this.Repos[$i].Path) } } } # Construye el frame como array de líneas. Sin Write-Host — los callers # deciden si lo emiten línea por línea (estático) o como blob a stderr (loop). # Lee $this.Repos / $this.CurrentPath / $this.Filter / $this.Focus internamente. hidden [string[]] BuildLines([string]$version) { $reset = [AnsiService]::Reset $r = $this.Renderer # Locales con prefijo `all*` y `vis*` para evitar choque con la property # $this.Repos (PS classes son case-insensitive en scope). $rootPath = $this.CurrentPath $allRepos = $this.Repos $visible = $this.FilteredRepos() $lines = [System.Collections.Generic.List[string]]::new() # 1. Title bar $lines.Add($this.Frame.TitleBar('repo-nav', "Workspace · Personal", $version)) # 2. AppHeader: breadcrumb dinámico + tabs SOLO cuando el menu está abierto. # Si Focus=Breadcrumb, destacamos el segmento activo (índice). $bc = [BreadcrumbBuilder]::Build($rootPath) $tabs = @() if ($this.Focus -eq 'Header') { $tabDefs = @( @{ k = 'Spc'; label = $this.T('tab.browse') } @{ k = 'S'; label = $this.T('tab.search') } @{ k = 'B'; label = $this.T('tab.git') } ) for ($ti = 0; $ti -lt $tabDefs.Count; $ti++) { $tabs += @{ k = $tabDefs[$ti].k label = $tabDefs[$ti].label focused = ($ti -eq $this.ActiveTab) } } } # Si Focus=Breadcrumb, mapeamos el BreadcrumbIndex (que es contra los # AbsolutePaths completos, sin colapso) al índice DESPLAYED del bc.Segs. # Build() puede haber colapsado con … — en ese caso saltamos el destacar. $activeBcIdx = -1 if ($this.Focus -eq 'Breadcrumb' -and $bc.Segs -notcontains '…') { # bc.Segs son los ancestors; bc.Current es el último. # AbsolutePaths total = ancestors.Count + 1 (current). # El índice del BreadcrumbBuilder.BuildWithPaths matchea aquí 1:1. $activeBcIdx = $this.BreadcrumbIndex } $lines.Add($this.AppHeader.Render($bc.Segs, $bc.Current, $tabs, $activeBcIdx)) $lines.Add($r.HRule()) # Modo según ancho de consola: wide ≥ 100, compact < 100. # Se calcula acá arriba porque el title necesita saber el slice visible. $width = $r.Width() $compact = $width -lt 100 # Slice del viewport sobre la lista filtrada. NavStack=null = modo static. if ($null -ne $this.NavStack) { $sliceStart = $this.Viewport.Start $sliceEnd = $this.Viewport.EndExclusive($visible.Count) } else { $sliceStart = 0 $sliceEnd = $visible.Count } # 3. Línea entre header y title: prompt de filter (si Focus=Filter) o vacío. # Altura constante para que la lista no salte al entrar/salir del filter. # Cuando Focus=Filter pintamos un pill destacado [ Buscar ] + cursor de input, # más un hint a la derecha — muy visible para que se note el modo activo. if ($this.Focus -eq 'Filter') { $bg = $this.Theme.Bg('acc') $fgInv = $this.Theme.Fg('bg0') $pill = "${bg}${fgInv} $($this.T('filter.search')) ${reset}" $cursor = $this.Theme.Fg('acc') + '|' + $reset $buf = $this.Theme.Fg('fg0') + $this.InputBuffer + $reset $hint = $this.Theme.Fg('fg3') + ' ' + $this.T('filter.acceptHint') + $reset $lines.Add(' ' + $pill + ' ' + $buf + $cursor + $hint) } elseif ($this.Focus -eq 'AliasInput') { $bg = $this.Theme.Bg('acc') $fgInv = $this.Theme.Fg('bg0') $pill = "${bg}${fgInv} $($this.T('alias.label')) ${reset}" $cursor = $this.Theme.Fg('acc') + '|' + $reset $buf = $this.Theme.Fg('fg0') + $this.InputBuffer + $reset # Hint dinámico: si el repo ya tenía alias, indicamos que borrando todo # + Enter abre el confirm de eliminación. Sino la guía clásica de set. $curAlias = if ($visible.Count -gt 0) { $visible[$this.SelectedIndex] } else { $null } $hadAlias = ($null -ne $curAlias) -and [bool]$this.GetAliasFor($curAlias) $hintText = if ($hadAlias) { $this.T('alias.hintWithAlias') } else { $this.T('alias.hintNoAlias') } $hint = $this.Theme.Fg('fg3') + ' ' + $hintText + $reset $lines.Add(' ' + $pill + ' ' + $buf + $cursor + $hint) } elseif ($this.Focus -eq 'AliasConfirmRemove') { $cur = if ($visible.Count -gt 0) { $visible[$this.SelectedIndex] } else { $null } $aliasInfo = if ($cur) { $this.GetAliasFor($cur) } else { $null } $aliasName = if ($aliasInfo) { [string]$aliasInfo.alias } else { '' } $msg = if ($aliasName) { $this.T('alias.confirmRemove', @($aliasName)) } else { $this.T('alias.confirmRemoveNoName') } $lines.Add(' ' + $this.Theme.Fg('gitDirty') + $msg + $reset) } else { $lines.Add('') } # 4. Sección title — total, filter y rango visible cuando hay scroll. $titleText = ' ' + $this.T('counts.repos') + ' · ' + $visible.Count if ($this.Filter -and $visible.Count -ne $allRepos.Count) { $titleText += ' / ' + $allRepos.Count + " · /$($this.Filter)/" } elseif ($this.Filter) { $titleText += " · /$($this.Filter)/" } if ($sliceStart -gt 0 -or $sliceEnd -lt $visible.Count) { $titleText += ' · ' + $this.T('counts.viewing') + ' ' + ($sliceStart + 1) + '–' + $sliceEnd } $lines.Add($this.Theme.Fg('fg2') + $titleText + $reset) $lines.Add($r.HRule()) # 4. Filas de repos. # Conteo de status sobre TODOS los repos (no sobre el slice del viewport). $countClean = 0; $countDirty = 0; $countUnpushed = 0; $countBehind = 0 $countConflict = 0; $countNoGit = 0; $countUnloaded = 0 foreach ($repo in $allRepos) { switch ($repo.Status) { 'clean' { $countClean++ } 'dirty' { $countDirty++ } 'unpushed' { $countUnpushed++ } 'behind' { $countBehind++ } 'conflict' { $countConflict++ } 'nogit' { $countNoGit++ } 'unloaded' { $countUnloaded++ } } } $totalRepos = $allRepos.Count $loadedRepos = $totalRepos - $countUnloaded if ($visible.Count -eq 0) { $msg = if ($this.Filter) { ' ' + $this.T('counts.noMatches', @($this.Filter)) } else { ' ' + $this.T('counts.noRepos') } $lines.Add($this.Theme.Fg('fg3') + $msg + $reset) } for ($i = $sliceStart; $i -lt $sliceEnd; $i++) { $repo = $visible[$i] $isSelected = ($i -eq $this.SelectedIndex) if ($compact) { $lines.Add($this.RepoRowCompact($repo, $isSelected, $width)) } else { $lines.Add($this.RepoRowWide($repo, $isSelected, $width)) } } $lines.Add($r.HRule()) # 5. StatusBar $cleanFg = $this.Theme.Fg('gitClean') $dirtyFg = $this.Theme.Fg('gitDirty') $unpushedFg = $this.Theme.Fg('gitUnpushed') $conflictFg = $this.Theme.Fg('gitConflict') $nogitFg = $this.Theme.Fg('gitNoGit') $accFg = $this.Theme.Fg('acc') $fg2 = $this.Theme.Fg('fg2') $behindFg = $this.Theme.Fg('gitBehind') # Si hay repos sin cargar (modo AutoLoad=Favorites/None o discovery # paralelo en curso), mostramos progreso "● 12/48 cargados" antes # del breakdown de status. Cuando todos cargados, desaparece. $progressPrefix = '' if ($countUnloaded -gt 0) { $loadFg = $this.Theme.Fg('gitDirty') $loadedLabel = $this.T('counts.loadedOf', @($loadedRepos, $totalRepos)) $progressPrefix = "${loadFg}● ${loadedLabel}${reset} ${fg2}│${reset} " } $cacheLabel = $this.T('counts.cache') $right = "${progressPrefix}${cleanFg}● $countClean${reset} ${dirtyFg}● $countDirty${reset} ${unpushedFg}● $countUnpushed${reset} ${behindFg}● $countBehind${reset} ${conflictFg}● $countConflict${reset} ${nogitFg}● $countNoGit${reset} ${fg2}│${reset} ${accFg}${cacheLabel} 0s${reset}" if ($this.Focus -eq 'Filter') { # Modo búsqueda: hints específicos. $items = @( @{ k = $this.T('hint.type'); label = $this.T('hint.search') } @{ k = '↵'; label = $this.T('hint.accept') } @{ k = 'Esc'; label = $this.T('hint.cancel') } @{ k = 'Q'; label = $this.T('hint.quit') } ) } elseif ($this.Focus -eq 'Breadcrumb') { $items = @( @{ k = '←→'; label = $this.T('hint.segment') } @{ k = '↵'; label = $this.T('hint.drill') } @{ k = 'Tab'; label = $this.T('hint.back') } @{ k = 'Esc'; label = $this.T('hint.cancel') } @{ k = 'Q'; label = $this.T('hint.quit') } ) } elseif ($this.Focus -eq 'Header') { # Menu abierto: hints específicos del modo tab. ↑ y Esc ambos cierran # el menu — mostramos los dos para que el user no tenga que adivinar. $items = @( @{ k = '←→'; label = $this.T('hint.tab') } @{ k = '↵'; label = $this.T('hint.run') } @{ k = '↑/Esc'; label = $this.T('hint.close') } @{ k = 'Q'; label = $this.T('hint.quit') } ) } else { # Hints contextuales: cada binding solo se muestra si APORTA en este momento. $items = @( @{ k = '↑↓'; label = $this.T('hint.move') } ) # → cambia de etiqueta según el tipo del item bajo cursor; desaparece si no hay acción. if ($visible.Count -gt 0) { $curItem = $visible[$this.SelectedIndex] if ($curItem.IsContainer) { $items += @{ k = '→'; label = $this.T('hint.openFolder') } } elseif ($curItem.Status -ne 'nogit') { $items += @{ k = '→'; label = $this.T('hint.openMenu') } } } # ← solo aporta si hay algo en el stack para volver. if ($null -ne $this.NavStack -and -not $this.NavStack.IsEmpty()) { $items += @{ k = '←'; label = $this.T('hint.back') } } $items += @{ k = '↵'; label = $this.T('hint.open') } $items += @{ k = 'Tab'; label = $this.T('hint.path') } $items += @{ k = 'S'; label = $this.T('hint.search') } $items += @{ k = 'F'; label = $this.T('hint.fav') } # Una sola tecla 'a' cubre todo el flow del alias: set, edit y delete. # En el prompt, si vacías el buffer y le das Enter, pregunta si borrar # — sin segundos atajos confusos. if ($visible.Count -gt 0) { $curAlias = $visible[$this.SelectedIndex] if (-not $curAlias.IsContainer -and $curAlias.Status -ne 'nogit') { $aliasLabel = if ($this.GetAliasFor($curAlias)) { $this.T('hint.aliasEdit') } else { $this.T('hint.aliasSet') } $items += @{ k = 'A'; label = $aliasLabel } } } $items += @{ k = 'R'; label = $this.T('hint.reload') } $items += @{ k = 'U'; label = $this.T('hint.prefs') } $items += @{ k = 'Q'; label = $this.T('hint.quit') } } # RenderAnchored padea con líneas vacías para que el statusbar quede # anclado al fondo de la consola (sino flota pegado al último item y # varía según cuántos repos haya). Helper compartido con todas las # screens — ver StatusBar.RenderAnchored. return @($lines.ToArray()) + $this.StatusBar.RenderAnchored($items, $right, $lines.Count) } # Genera el "decorador" izquierdo (border + cursor) y devuelve también el fg del nombre. hidden [hashtable] RowDecor([bool]$selected) { $reset = [AnsiService]::Reset $fg0 = $this.Theme.Fg('fg0') $fg3 = $this.Theme.Fg('fg3') $acc = $this.Theme.Fg('acc') if ($selected) { return @{ Bar = "${acc}▎${reset}" Cursor = "${acc}▶${reset}" NameFg = $acc Indent = "${acc}▎${reset} " } } return @{ Bar = ' ' Cursor = "${fg3}·${reset}" NameFg = $fg0 Indent = ' ' } } # Pill enriquecido: status base + sufijos ▲N/▼M cuando aplican. # Esto reemplaza la columna ahead/behind separada. hidden [string] RichPill([object]$repo) { # Container > status: una carpeta-agrupadora no es "no git", es estructural. if ($repo.IsContainer) { return $this.Primitives.Pill('container', $this.T('pill.container')) } switch ($repo.Status) { 'clean' { return $this.Primitives.Pill('clean', $this.T('pill.clean')) } 'conflict' { return $this.Primitives.Pill('conflict', $this.T('pill.conflict')) } 'nogit' { return $this.Primitives.Pill('nogit', $this.T('pill.nogit')) } 'unpushed' { return $this.Primitives.Pill('unpushed', "$($this.T('pill.unpushed')) · ▲$($repo.Ahead)") } 'behind' { return $this.Primitives.Pill('behind', "$($this.T('pill.behind')) · ▼$($repo.Behind)") } 'unloaded' { # Estado pasivo: el repo todavía no se cargó. NO usamos spinner # porque no hay actividad real — sería visualmente engañoso # (el user piensa que está cargando cuando en realidad está # esperando que pulse R o navegue encima). return $this.Primitives.Pill('nogit', $this.T('pill.notLoaded')) } 'dirty' { $base = "$($this.T('pill.dirty')) · $($repo.DirtyCount())" if ($repo.Ahead -gt 0) { $base += " · ▲$($repo.Ahead)" } if ($repo.Behind -gt 0) { $base += " · ▼$($repo.Behind)" } return $this.Primitives.Pill('dirty', $base) } } return '' } # Wide layout (≥100 cols): una línea con name | branch | pill | last-commit. # Devuelve " · fetch 12 min" si el repo tiene ahead/behind y conocemos el # último fetch. Sino string vacío. Ayuda al user a saber si la info de # ahead/behind puede estar desactualizada (red lenta + nunca refetcheás). hidden [string] FormatFetchAgo([object]$repo) { if ($null -eq $this.Settings) { return '' } if ($repo.Status -notin @('unpushed', 'behind', 'dirty', 'clean')) { return '' } if ($repo.Status -eq 'clean' -and $repo.Ahead -eq 0 -and $repo.Behind -eq 0) { return '' } $last = $this.Settings.GetLastFetch($repo.Id) if ($null -eq $last) { return '' } $reset = [AnsiService]::Reset return $this.Theme.Fg('fg3') + ' · fetch ' + [Renderer]::HumanizeAgo($last).Replace('hace ', '') + $reset } hidden [string] RepoRowWide([object]$repo, [bool]$selected, [int]$width) { $reset = [AnsiService]::Reset $fg3 = $this.Theme.Fg('fg3') $d = $this.RowDecor($selected) $check = "$($d.Bar) $($d.Cursor) " $nameFmt = $this.FormatRowName($repo, $d.NameFg) $name = [Renderer]::PadRight($nameFmt, 26) $branchText = if ($repo.Branch -and $repo.Status -ne 'nogit') { $this.Primitives.BranchChip($repo.Branch, $repo.IsOnMainBranch) } else { "${fg3}—${reset}" } $branchCell = [Renderer]::PadRight($branchText, 28) $pillRaw = $this.RichPill($repo) + $this.FormatFetchAgo($repo) $pillCell = [Renderer]::PadRight($pillRaw, 32) # Last commit ocupa el resto del ancho disponible. # Separadores: ' ' entre columnas para que branch truncado con … no quede pegado al pill. $usedFixed = 4 + 26 + 2 + 28 + 2 + 32 + 2 # check + name + sep + branch + sep + pill + sep $lcRoom = [Math]::Max(20, $width - $usedFixed) $lc = '' if ($repo.LastCommit) { $msgFg = $this.Theme.Fg('fg1') $msg = $repo.LastCommit.Message $dateStr = " · $($repo.LastCommit.Date)" $msgRoom = [Math]::Max(8, $lcRoom - $dateStr.Length) if ($msg.Length -gt $msgRoom) { $msg = $msg.Substring(0, $msgRoom - 1) + '…' } $lc = "${msgFg}${msg}${reset}${fg3}${dateStr}${reset}" } return "$check$name $branchCell $pillCell $lc" } # Compact layout (<100 cols): dos líneas por repo. # Línea 1: cursor + name + branch # Línea 2: indent + pill + last-commit hidden [string] RepoRowCompact([object]$repo, [bool]$selected, [int]$width) { $reset = [AnsiService]::Reset $fg3 = $this.Theme.Fg('fg3') $d = $this.RowDecor($selected) $check = "$($d.Bar) $($d.Cursor) " # Línea 1 — name + branch $nameRoom = [Math]::Min(28, [Math]::Max(12, [int]($width * 0.4))) $nameFmt = $this.FormatRowName($repo, $d.NameFg) $name = [Renderer]::PadRight($nameFmt, $nameRoom) $branchRoom = [Math]::Max(12, $width - 4 - $nameRoom - 2) $branchText = if ($repo.Branch -and $repo.Status -ne 'nogit') { $this.Primitives.BranchChip($repo.Branch, $repo.IsOnMainBranch) } else { "${fg3}—${reset}" } $branchCell = [Renderer]::PadRight($branchText, $branchRoom) $line1 = "$check$name$branchCell" # Línea 2 — indent (sin cursor) + pill + last-commit $pillRoom = 26 $pillCell = [Renderer]::PadRight($this.RichPill($repo), $pillRoom) $lcRoom = [Math]::Max(15, $width - 4 - $pillRoom - 2) $lc = '' if ($repo.LastCommit) { $msgFg = $this.Theme.Fg('fg1') $msg = $repo.LastCommit.Message $dateStr = " · $($repo.LastCommit.Date)" $msgRoom = [Math]::Max(8, $lcRoom - $dateStr.Length) if ($msg.Length -gt $msgRoom) { $msg = $msg.Substring(0, $msgRoom - 1) + '…' } $lc = "${msgFg}${msg}${reset}${fg3}${dateStr}${reset}" } $line2 = "$($d.Indent)$pillCell$lc" return "$line1`e[K`n$line2" } # Sincroniza el Viewport con la geometría de la terminal y el cursor actual. # Chrome fijo = 5 arriba (titlebar + appheader + hr + section title + hr) + 2 abajo # (hr + statusbar). En modo compact cada repo ocupa 2 filas físicas; en wide, 1. # La lógica del scroll vive en la clase Viewport — acá solo le pasamos la geometría. hidden [void] EnsureViewport() { $count = $this.FilteredRepos().Count if ($count -eq 0) { $this.Viewport.Reset() return } $width = $this.Renderer.Width() $compact = $width -lt 100 $rowsPerRepo = if ($compact) { 2 } else { 1 } # Chrome: titlebar + appheader + hr + prompt/status + sectionTitle + hr + 2hr + statusbar(2 líneas) = 8. $available = [Console]::WindowHeight - 8 $this.Viewport.Resize($available, $rowsPerRepo) $this.Viewport.EnsureVisible($this.SelectedIndex, $count) } # Push del frame actual + scan del container y reset de selección. hidden [void] DrillIn([object]$container) { $this.NavStack.Push(@{ Path = $this.CurrentPath Repos = $this.Repos SelectedIndex = $this.SelectedIndex ViewportStart = $this.Viewport.Start }) $disc = [RepoDiscoveryService]::new($this.Git) $this.CurrentPath = $container.Path $this.Repos = $disc.Discover($container.Path) $this.SelectedIndex = 0 $this.Viewport.Reset() } # Drill al path absoluto dado (vía clic en breadcrumb). Si el target ES un # ancestor en el NavStack, hacemos pop hasta llegar (preservando histórico). # Si no, reseteamos: discoverize fresh + NavStack vacío. hidden [void] DrillToPath([string]$absPath) { if (-not $absPath) { return } if ($absPath -eq $this.CurrentPath) { return } # Pop frames hasta matchear el target (caso normal: ancestor en stack). while ($null -ne $this.NavStack -and -not $this.NavStack.IsEmpty()) { if ($this.CurrentPath -eq $absPath) { break } $this.DrillOut() } # Si no llegamos por pop (target no estaba en stack), reset hard. if ($this.CurrentPath -ne $absPath) { if (-not (Test-Path -LiteralPath $absPath)) { # Path inválido — no hacemos nada, dejamos el state como está. return } $disc = [RepoDiscoveryService]::new($this.Git) $this.CurrentPath = $absPath $this.Repos = $disc.Discover($absPath) $this.SelectedIndex = 0 $this.Viewport.Reset() if ($null -ne $this.NavStack) { $this.NavStack.Clear() } } } # Pop del stack y restauración del frame previo. Noop si stack vacío # (no escapamos del root original — patrón conservador, file-system-wide nav queda para más adelante). hidden [void] DrillOut() { if ($this.NavStack.IsEmpty()) { return } # `$frame` chocaría con la property `$this.Frame` (case-insensitive en PS class scope). $prev = $this.NavStack.Pop() $this.CurrentPath = $prev.Path $this.Repos = $prev.Repos $this.SelectedIndex = $prev.SelectedIndex $this.Viewport.Start = $prev.ViewportStart } # Pantalla placeholder para destinos no implementados todavía. # Mismo chrome que MainScreen (titlebar arriba, statusbar abajo) + cuerpo centrado. # Bindings: ←/h/Esc vuelve al main; Q sale de la app (se propaga via QuitRequested). hidden [void] ShowStub([string]$destination) { # Sin ClearScreen al entrar ni al salir — cada frame limpia con MoveTo + ESC[J. $errOut = [Console]::Error while ($true) { $lines = $this.BuildStubLines($destination) $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) $k = $key.Key $c = $key.KeyChar if ($c -eq 'q' -or $c -eq 'Q') { $this.QuitRequested = $true break } if ($k -eq 'Escape' -or $k -eq 'LeftArrow' -or $c -eq 'h') { break } } } hidden [string[]] BuildStubLines([string]$destination) { $reset = [AnsiService]::Reset $r = $this.Renderer $width = $r.Width() $lines = [System.Collections.Generic.List[string]]::new() # 1. Title bar reusada $lines.Add($this.Frame.TitleBar('repo-nav', "$destination · stub", '3.0.0-dev')) # 2. Header simple — segmento "main" + current = $destination. # Preferences NO está en el tab menu (binding 'U' directo desde la lista). $tabs = @( @{ k = 'Spc'; label = $this.T('tab.browse'); active = ($destination -eq 'Browse') } @{ k = 'S'; label = $this.T('tab.search'); active = ($destination -eq 'Search') } @{ k = 'B'; label = $this.T('tab.git'); active = ($destination -eq 'BranchManager') } ) $lines.Add($this.AppHeader.Render(@('repo-nav'), $destination, $tabs)) $lines.Add($r.HRule()) # 3. Cuerpo centrado vertical y horizontalmente. $consoleHeight = [Console]::WindowHeight $chromeTop = 3 # title + header + hr $chromeBottom = 2 # hr + statusbar $bodyRows = [Math]::Max(3, $consoleHeight - $chromeTop - $chromeBottom) $msgFg = $this.Theme.Fg('fg2') $headFg = $this.Theme.Fg('acc') $captionFg = $this.Theme.Fg('fg3') $head = "${headFg}próximamente${reset}" $body = "${msgFg}${destination}${reset}" $caption = "${captionFg}placeholder — el destino real llega en otro PR${reset}" $headPad = [Math]::Max(0, [int](($width - [Renderer]::VisibleLength($head)) / 2)) $bodyPad = [Math]::Max(0, [int](($width - [Renderer]::VisibleLength($body)) / 2)) $captionPad = [Math]::Max(0, [int](($width - [Renderer]::VisibleLength($caption)) / 2)) $topPad = [Math]::Max(0, [int](($bodyRows - 3) / 2)) for ($i = 0; $i -lt $topPad; $i++) { $lines.Add('') } $lines.Add((' ' * $headPad) + $head) $lines.Add((' ' * $bodyPad) + $body) $lines.Add('') $lines.Add((' ' * $captionPad) + $caption) $bottomFiller = [Math]::Max(0, $bodyRows - $topPad - 4) for ($i = 0; $i -lt $bottomFiller; $i++) { $lines.Add('') } # 4. Statusbar simple $lines.Add($r.HRule()) $items = @( @{ k = '←'; label = $this.T('hint.back') } @{ k = 'Esc'; label = $this.T('hint.back') } @{ k = 'Q'; label = $this.T('hint.quit') } ) return @($lines.ToArray()) + $this.StatusBar.RenderAnchored($items, '', $lines.Count) } } |