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)
    }

}