src/UI/Screens/BrowseScreen.ps1

# BrowseScreen — Filesystem browser con breadcrumb navegable (réplica v2 FolderBrowser).
#
# Permite al usuario navegar el filesystem hasta una carpeta y devolverla al
# caller (MainScreen la usa para "ir a otra carpeta").
#
# Layout:
# titlebar
# appheader (breadcrumb interactivo si Focus=Breadcrumb)
# ─
# prompt/status (search input cuando Focus=Search)
# ─
# lista de items (carpetas):
# · .. (parent)
# · folder-1
# · folder-2
# ...
# ─
# statusbar
#
# 3 focus modes:
# list — navegar items (↑↓), Enter entra carpeta, '..' sube
# search — tipear para filtrar la lista
# breadcrumb — ←→ entre segmentos del path, Enter drill al absolute path
#
# Tab cicla list → search → breadcrumb → list. Esc cancel. E selecciona el
# current path como resultado y vuelve.

class BrowseScreen {
    [object] $Theme
    [object] $Renderer
    [object] $Primitives
    [object] $Frame
    [object] $AppHeader
    [object] $StatusBar
    [object] $I18n = $null   # Inyectable post-construct.

    [string]   $CurrentPath = ''
    [string]   $StartPath = ''
    [object]   $Picker = $null     # FilteredListPicker — items + filter + navegación
    [string]   $Mode = 'browsing'  # 'browsing' | 'breadcrumb'
    [int]      $BreadcrumbIndex = 0

    # Resultado del flow
    [bool]     $Cancelled = $true
    [string]   $SelectedPath = ''
    [bool]     $QuitRequested = $false
    [bool]     $UseAltBuffer = $true

    [string]   $StatusMessage = ''
    [string]   $StatusKind = ''

    BrowseScreen($theme, $renderer, $primitives, $frame, $appHeader, $statusBar) {
        $this.Theme       = $theme
        $this.Renderer    = $renderer
        $this.Primitives  = $primitives
        $this.Frame       = $frame
        $this.AppHeader   = $appHeader
        $this.StatusBar   = $statusBar
    }

    # Abre el browser desde un path inicial. Loop hasta que user selecciona o cancela.
    [void] Open([string]$initialPath) {
        # `$startPath` choca con property $this.StartPath (PS class CI). Usamos
        # `$initialPath` como nombre de la local.
        if ([string]::IsNullOrWhiteSpace($initialPath)) {
            $initialPath = (Get-Location).Path
        }
        if (-not (Test-Path -LiteralPath $initialPath)) {
            $initialPath = [System.Environment]::GetFolderPath('UserProfile')
        }

        $this.StartPath = $initialPath
        $this.CurrentPath = $initialPath
        $this.Picker = [FilteredListPicker]::new()
        $this.Picker.Title = 'Filtrar'
        $this.Picker.LabelFn = { param($item) [string]$item.Name }
        $this.Picker.AlwaysShowFn = { param($item) $item.Type -eq 'parent' }
        $this.Picker.RenderRowFn = {
            param($item, $sel, $theme, $reset)
            $marker = if ($sel) { $theme.Fg('acc') + '▎ ▶ ' + $reset } else { ' · ' }
            if ($item.Type -eq 'parent') {
                return $marker + $theme.Fg('fg2') + '..' + $reset + ' ' + $theme.Fg('fg3') + '(carpeta padre)' + $reset
            }
            $color = if ($sel) { 'acc' } else { 'fg1' }
            return $marker + $theme.Fg($color) + $item.Name + $reset
        }

        $this.Mode = 'browsing'
        $this.BreadcrumbIndex = 0
        $this.Cancelled = $true
        $this.SelectedPath = ''
        $this.QuitRequested = $false
        $this.LoadItems()

        if ([Console]::IsInputRedirected) {
            $lines = $this.BuildLines()
            foreach ($l in $lines) { Write-Host $l }
            return
        }

        $errOut = [Console]::Error
        $this.Render($errOut)

        try {
            while ($true) {
                $key = [Console]::ReadKey($true)
                $exit = $this.HandleKey($key)
                if ($exit -eq 'done') { return }
                if ($exit -eq 'quit') { $this.QuitRequested = $true; return }
                $this.Render($errOut)
            }
        }
        finally { }
    }

    # Lee el contenido del CurrentPath. Solo carpetas (incluye '..').
    hidden [void] LoadItems() {
        # Lazy-init del Picker para que tests puedan llamar LoadItems sin Open().
        if ($null -eq $this.Picker) {
            $this.Picker = [FilteredListPicker]::new()
            $this.Picker.LabelFn = { param($item) [string]$item.Name }
            $this.Picker.AlwaysShowFn = { param($item) $item.Type -eq 'parent' }
        }
        $entries = @()
        # Split-Path tira PSArgumentException en POSIX al pasarle '/' (root sin
        # parent). En Windows con 'C:\' devuelve '' silenciosamente. Defensivo
        # para tratar ambos casos como "sin parent".
        $parent = $null
        try { $parent = Split-Path -Path $this.CurrentPath -Parent } catch { $parent = $null }
        if ($parent -and (Test-Path -LiteralPath $parent)) {
            $entries += @{ Name = '..'; Type = 'parent'; Path = $parent }
        }

        try {
            $dirs = Get-ChildItem -LiteralPath $this.CurrentPath -Directory -ErrorAction Stop |
                Sort-Object Name
            foreach ($d in $dirs) {
                if ($d.Attributes -band [System.IO.FileAttributes]::Hidden) { continue }
                if ($d.Attributes -band [System.IO.FileAttributes]::System) { continue }
                $entries += @{ Name = $d.Name; Type = 'folder'; Path = $d.FullName }
            }
        } catch {
            $this.SetStatus("no se pudo leer: $($_.Exception.Message)", 'error')
        }

        $this.Picker.SetItems($entries)
    }

    # Helpers para tests legacy + render.
    [object[]] Items() { return $this.Picker.Items }
    [object[]] FilteredItems() { return $this.Picker.FilteredItems() }

    hidden [void] Render([object]$errOut) {
        if ([Console]::IsInputRedirected) { return }
        $lines = $this.BuildLines()
        $framePayload = [AnsiService]::BeginSync() + [AnsiService]::MoveTo(1, 1) + ($lines -join "`e[K`n") + "`e[K`e[J" + [AnsiService]::EndSync()
        $errOut.Write($framePayload)
        $errOut.Flush()
    }

    hidden [string] HandleKey([System.ConsoleKeyInfo]$key) {
        switch ($this.Mode) {
            'browsing'    { return $this.HandleKeyBrowsing($key) }
            'breadcrumb'  { return $this.HandleKeyBreadcrumb($key) }
        }
        return ''
    }

    # Browsing combina list + search. Bindings específicos del browser primero
    # (q, Tab, E), después delega al picker que maneja navegación + filter.
    hidden [string] HandleKeyBrowsing([System.ConsoleKeyInfo]$key) {
        $c = $key.KeyChar
        $k = $key.Key

        # Q quit es global.
        if (($c -eq 'q' -or $c -eq 'Q') -and -not $this.Picker.Filter) { return 'quit' }
        # E selecciona el current path. Solo si filter vacío para que no choque
        # con tipear 'e' en el filter.
        if (($c -eq 'E') -or ($c -eq 'e' -and -not $this.Picker.Filter)) {
            $this.SelectedPath = $this.CurrentPath
            $this.Cancelled = $false
            return 'done'
        }
        # Tab → focus al breadcrumb arriba.
        if ($k -eq 'Tab') {
            $bcInfo = [BreadcrumbBuilder]::BuildWithPaths($this.CurrentPath)
            if ($bcInfo.AbsolutePaths.Count -gt 0) {
                $this.Mode = 'breadcrumb'
                $this.BreadcrumbIndex = $bcInfo.AbsolutePaths.Count - 1
            }
            return ''
        }

        # Resto al picker.
        $action = $this.Picker.HandleKey($key)
        if ($action -eq 'enter') {
            $sel = $this.Picker.Selected()
            if ($sel) { $this.EnterFolder($sel.Path) }
        } elseif ($action -eq 'escape') {
            # Esc con filter activo → limpia filter (vuelve a la lista completa).
            # Esc con filter vacío → cancela el browser entero.
            if ($this.Picker.Filter) {
                $this.Picker.Reset()
            } else {
                $this.Cancelled = $true
                return 'done'
            }
        }
        return ''
    }

    hidden [string] HandleKeyBreadcrumb([System.ConsoleKeyInfo]$key) {
        $k = $key.Key
        $c = $key.KeyChar
        $bcInfo = [BreadcrumbBuilder]::BuildWithPaths($this.CurrentPath)
        $segCount = $bcInfo.AbsolutePaths.Count

        if ($c -eq 'q' -or $c -eq 'Q') { return 'quit' }
        if ($k -eq 'Escape') { $this.Mode = 'list'; return '' }
        if ($k -eq 'Tab' -or $k -eq 'DownArrow' -or $c -eq 'j') { $this.Mode = 'list'; return '' }

        if ($k -eq 'LeftArrow' -or $c -eq 'h') {
            if ($segCount -gt 0) {
                $this.BreadcrumbIndex = if ($this.BreadcrumbIndex -le 0) { $segCount - 1 } else { $this.BreadcrumbIndex - 1 }
            }
            return ''
        }
        if ($k -eq 'RightArrow' -or $c -eq 'l') {
            if ($segCount -gt 0) {
                $this.BreadcrumbIndex = if ($this.BreadcrumbIndex -ge $segCount - 1) { 0 } else { $this.BreadcrumbIndex + 1 }
            }
            return ''
        }
        if ($k -eq 'Enter') {
            if ($this.BreadcrumbIndex -ge 0 -and $this.BreadcrumbIndex -lt $segCount) {
                $target = $bcInfo.AbsolutePaths[$this.BreadcrumbIndex]
                $this.EnterFolder($target)
            }
            $this.Mode = 'list'
            return ''
        }

        if ($c -eq 'e' -or $c -eq 'E') {
            $this.SelectedPath = $this.CurrentPath
            $this.Cancelled = $false
            return 'done'
        }
        return ''
    }

    hidden [void] EnterFolder([string]$path) {
        if ([string]::IsNullOrWhiteSpace($path)) { return }
        if (-not (Test-Path -LiteralPath $path)) {
            $this.SetStatus("path no existe: $path", 'error')
            return
        }
        $this.CurrentPath = $path
        $this.Picker.Reset()
        $this.SetStatus('', '')
        $this.LoadItems()
    }

    hidden [void] SetStatus([string]$msg, [string]$kind) {
        $this.StatusMessage = $msg
        $this.StatusKind = $kind
    }

    [string[]] BuildLines() {
        $reset = [AnsiService]::Reset
        $r = $this.Renderer
        $lines = [System.Collections.Generic.List[string]]::new()

        # Title bar
        $lines.Add($this.Frame.TitleBar('repo-nav', "Browse · $($this.CurrentPath)", '1.0.0'))

        # AppHeader: breadcrumb. Si Mode=breadcrumb destacamos el segmento activo.
        $bc = [BreadcrumbBuilder]::Build($this.CurrentPath)
        $activeBcIdx = -1
        if ($this.Mode -eq 'breadcrumb' -and $bc.Segs -notcontains '…') {
            $activeBcIdx = $this.BreadcrumbIndex
        }
        $lines.Add($this.AppHeader.Render($bc.Segs, $bc.Current, @(), $activeBcIdx))
        $lines.Add($r.HRule())

        # Status line (status message si hay; sino vacío. El picker tiene su
        # propio prompt incluido en BuildBody).
        if ($this.StatusMessage -and $this.Mode -eq 'browsing') {
            $color = if ($this.StatusKind -eq 'error') { 'gitConflict' } else { 'gitClean' }
            $lines.Add(' ' + $this.Theme.Fg($color) + $this.StatusMessage + $reset)
        } else {
            $lines.Add('')
        }

        if ($this.Mode -eq 'browsing') {
            # Body via picker — ya incluye filter prompt + count + lista.
            $maxVisible = [Math]::Max(5, [Console]::WindowHeight - 11)
            $body = $this.Picker.BuildBody($this.Theme, $r, $maxVisible)
            foreach ($l in $body) { $lines.Add($l) }
        } else {
            # Mode=breadcrumb: hint visual indicando que estás en el path arriba.
            $lines.Add($this.Theme.Fg('fg2') + ' Navegando el path arriba — ←→ entre segmentos, ↵ drill, Tab vuelve' + $reset)
            $lines.Add($r.HRule())
        }

        # StatusBar
        $hints = $this.StatusBarHints()
        $lines.Add($r.HRule())
        return @($lines.ToArray()) + $this.StatusBar.RenderAnchored($hints, '', $lines.Count)
    }

    hidden [string] T([string]$key) {
        if ($null -ne $this.I18n) { return $this.I18n.T($key) }
        return ([I18nService]::new('es')).T($key)
    }

    hidden [array] StatusBarHints() {
        switch ($this.Mode) {
            'breadcrumb' {
                return @(
                    @{ k = '←→';  label = $this.T('hint.segment') }
                    @{ k = '↵';   label = $this.T('hint.drill') }
                    @{ k = 'E';   label = $this.T('hint.accept') }
                    @{ k = 'Tab'; label = $this.T('hint.back') }
                    @{ k = 'Esc'; label = $this.T('hint.cancel') }
                )
            }
            default {
                return @(
                    @{ k = '↑↓'; label = $this.T('hint.move') }
                    @{ k = '↵';  label = $this.T('hint.open') }
                    @{ k = '←';  label = $this.T('hint.back') }
                    @{ k = 'E';  label = $this.T('hint.accept') }
                    @{ k = 'Tab'; label = $this.T('hint.search') }
                    @{ k = '/';   label = $this.T('hint.search') }
                    @{ k = 'Esc'; label = $this.T('hint.cancel') }
                    @{ k = 'Q';   label = $this.T('hint.quit') }
                )
            }
        }
        return @()
    }
}