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