Public/TUI.ps1

# PSSnips — Interactive terminal UI (Start-SnipManager).
# Full-screen terminal UI (TUI) built on raw console key input.
# Uses [Console]::SetCursorPosition for in-place screen redraws and
# $Host.UI.RawUI.ReadKey for single-keypress navigation without Enter.

function Start-SnipManager {
    <#
    .SYNOPSIS
        Launches the full-screen interactive terminal snippet manager (TUI).
 
    .DESCRIPTION
        Starts a full-screen text UI that displays a navigable list of snippets with
        real-time search filtering. The UI is drawn using [Console]::SetCursorPosition
        for in-place redraws without flickering. Navigation uses raw virtual key codes
        read via $Host.UI.RawUI.ReadKey:
          VK 38 (Up arrow) - move selection up
          VK 40 (Down arrow) - move selection down
          VK 13 (Enter) - open detail view
          VK 39 (Right arrow) - open detail view
          VK 27 (Esc) - return to list view from detail
          VK 37 (Left arrow) - return to list view from detail
        Single-character commands (n, e, r, c, d, g, /) are handled in the default
        branch of the key switch. The cursor is hidden during TUI operation and
        restored in a finally block to ensure visibility is not lost on error.
 
    .EXAMPLE
        Start-SnipManager
 
        Launches the interactive TUI snippet manager.
 
    .EXAMPLE
        snip
 
        Equivalent shortcut: calling snip with no arguments starts the TUI.
 
    .INPUTS
        None.
 
    .OUTPUTS
        None. All interaction is through the console.
 
    .NOTES
        Requires an interactive host with RawUI support. Will not work correctly
        in non-interactive sessions (e.g., CI pipelines) or when stdout is
        redirected. The TUI shows up to 20 snippets per page; use [/] to filter
        when the collection exceeds 20 items.
    #>

    [CmdletBinding()]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Start-SnipManager launches a read-only interactive TUI and does not change system state directly.')]
    param()
    script:InitEnv

    $idx   = script:LoadIdx
    $sel   = 0
    $mode  = 'list'   # 'list' | 'detail'
    $query = ''
    $msg   = ''

    function Get-Filtered {
        param([hashtable]$Idx, [string]$q)
        $allItems = @(foreach ($n in ($Idx.snippets.Keys | Sort-Object)) {
            $m = $Idx.snippets[$n]
            if (-not $q -or $n -like "*$q*" -or ($m.Description -like "*$q*") -or
                (($m.Tags -join ',') -like "*$q*")) {
                [pscustomobject]@{ Name = $n; Meta = $m; Pinned = $m.Pinned }
            }
        })
        # Pinned snippets float to the top
        $pinnedItems    = @($allItems | Where-Object { $_.Pinned })
        $unpinnedItems  = @($allItems | Where-Object { -not $_.Pinned })
        return @($pinnedItems) + @($unpinnedItems)
    }

    function Write-SnipList {
        param([array]$list, [int]$s, [string]$q, [string]$statusMsg)
        [Console]::SetCursorPosition(0,0)
        script:Out-Banner
        Write-Host (" {0,-6} {1,-5} {2}" -f 'Keys','','Actions') -ForegroundColor DarkGray
        Write-Host " [↑↓] Navigate [Enter/→] View [n] New [e] Edit [r] Run [c] Copy [d] Delete" -ForegroundColor DarkGray
        Write-Host " [g] Export Gist [/] Search [q] Quit" -ForegroundColor DarkGray
        if ($q) { Write-Host (" Filter: {0,-40}" -f $q) -ForegroundColor Yellow } else { Write-Host " $(' ' * 50)" }
        Write-Host " $('─' * 76)" -ForegroundColor DarkGray

        if ($list.Count -eq 0) {
            Write-Host " (no snippets — press [n] to create one)$(' ' * 20)" -ForegroundColor DarkGray
        } else {
            $visible = [Math]::Min($list.Count, 20)
            for ($i = 0; $i -lt $visible; $i++) {
                $item  = $list[$i]
                $c     = script:LangColor -ext $item.Meta.language
                $gmark = if ($item.Meta.gistId) { ' [G]' } else { ' ' }
                $desc  = if ($item.Meta.description) { " $($item.Meta.description)" } else { '' }
                $tags  = if (@($item.Meta.tags).Count -gt 0) { " [$( (@($item.Meta.tags) -join ',') )]" } else { '' }
                $pin   = if ($item.Pinned) { '★ ' } else { ' ' }
                $row   = "{0}{1,-24} {2,-6}{3}{4}{5}" -f $pin, $item.Name, $item.Meta.language, $gmark, $desc, $tags
                if ($row.Length -gt 74) { $row = $row.Substring(0,71) + '...' }
                if ($i -eq $s) {
                    Write-Host (" ► {0,-74}" -f $row) -BackgroundColor DarkBlue -ForegroundColor White
                } else {
                    Write-Host (" " + $row + (' ' * [Math]::Max(0, 74 - $row.Length))) -ForegroundColor $c
                }
            }
            if ($list.Count -gt 20) { Write-Host " ... and $($list.Count - 20) more. Use [/] to filter." -ForegroundColor DarkGray }
        }
        Write-Host " $('─' * 76)" -ForegroundColor DarkGray
        if ($statusMsg) { Write-Host (" {0,-76}" -f $statusMsg) -ForegroundColor Green } else { Write-Host (' ' * 80) }
        Write-Host "" 
    }

    function Write-SnipDetail {
        param([pscustomobject]$item)
        [Console]::SetCursorPosition(0,0)
        $c    = script:LangColor -ext $item.Meta.language
        $path = script:FindFile -Name $item.Name
        Write-Host ""
        Write-Host (" ╔═ {0} ({1}) ═╗" -f $item.Name, $item.Meta.language) -ForegroundColor $c
        if ($item.Meta.description) { Write-Host " $($item.Meta.description)" -ForegroundColor Gray }
        if ($item.Meta.gistUrl)     { Write-Host " Gist: $($item.Meta.gistUrl)" -ForegroundColor DarkCyan }
        if (@($item.Meta.tags).Count -gt 0) { Write-Host " Tags: $( (@($item.Meta.tags) -join ', '))" -ForegroundColor DarkGray }
        Write-Host " $('─' * 60)" -ForegroundColor DarkGray
        if ($path -and (Test-Path $path)) {
            $lines = Get-Content $path -Encoding UTF8
            $shown = [Math]::Min($lines.Count, 30)
            for ($i = 0; $i -lt $shown; $i++) { Write-Host " $($lines[$i])" }
            if ($lines.Count -gt 30) { Write-Host " ... ($($lines.Count - 30) more lines)" -ForegroundColor DarkGray }
        }
        Write-Host " $('─' * 60)" -ForegroundColor DarkGray
        Write-Host " [e] Edit [r] Run [c] Copy [g] Gist [Esc/←] Back" -ForegroundColor DarkGray
        Write-Host (' ' * 80)
    }

    try {
        [Console]::CursorVisible = $false
        Clear-Host

        :outer while ($true) {
            $list = Get-Filtered -Idx $idx -q $query
        if ($null -eq $list) { $list = @() }
            if ($sel -ge $list.Count) { $sel = [Math]::Max(0, $list.Count - 1) }

            switch ($mode) {
                'list'   { Write-SnipList   -list $list -s $sel -q $query -statusMsg $msg }
                'detail' { if ($list.Count -gt 0) { Clear-Host; Write-SnipDetail -item $list[$sel] } else { $mode = 'list' } }
            }
            $msg = ''

            $key = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown')
            $vk  = $key.VirtualKeyCode
            $ch  = $key.Character

            if ($mode -eq 'list') {
                switch ($vk) {
                    38 { if ($sel -gt 0) { $sel-- } }                       # Up
                    40 { if ($sel -lt $list.Count - 1) { $sel++ } }          # Down
                    { $_ -in 13,39 } { if ($list.Count -gt 0) { $mode = 'detail' } } # Enter / Right
                    default {
                        switch ($ch) {
                            'q' { break outer }
                            'n' {
                                [Console]::CursorVisible = $true
                                Clear-Host
                                $nm = Read-Host " New snippet name"
                                $la = Read-Host " Language [$( (script:LoadCfg).DefaultLanguage )]"
                                $de = Read-Host " Description (optional)"
                                [Console]::CursorVisible = $false
                                if ($nm) {
                                    if (-not $la) { $la = (script:LoadCfg).DefaultLanguage }
                                    New-Snip -Name $nm -Language $la -Description $de
                                    $idx = script:LoadIdx
                                    $msg = "[+] Created '$nm'"
                                }
                                Clear-Host
                            }
                            'e' {
                                if ($list.Count -gt 0) {
                                    [Console]::CursorVisible = $true
                                    Clear-Host; Edit-Snip -Name $list[$sel].Name
                                    $idx = script:LoadIdx
                                    $msg = "[+] Saved changes to '$($list[$sel].Name)'"
                                    [Console]::CursorVisible = $false; Clear-Host
                                }
                            }
                            'r' {
                                if ($list.Count -gt 0) {
                                    [Console]::CursorVisible = $true
                                    Clear-Host; Invoke-Snip -Name $list[$sel].Name
                                    Read-Host "`n [Press Enter to return]"
                                    [Console]::CursorVisible = $false; Clear-Host
                                }
                            }
                            'c' {
                                if ($list.Count -gt 0) { Copy-Snip -Name $list[$sel].Name; $msg = "[+] Copied to clipboard" }
                            }
                            'd' {
                                if ($list.Count -gt 0) {
                                    [Console]::CursorVisible = $true
                                    $yn = Read-Host " Delete '$($list[$sel].Name)'? [y/N]"
                                    [Console]::CursorVisible = $false
                                    if ($yn -in 'y','Y') {
                                        Remove-Snip -Name $list[$sel].Name -Force
                                        $idx = script:LoadIdx
                                        if ($sel -ge $list.Count - 1 -and $sel -gt 0) { $sel-- }
                                        $msg = "[+] Deleted"
                                    }
                                    Clear-Host
                                }
                            }
                            'g' {
                                if ($list.Count -gt 0) {
                                    [Console]::CursorVisible = $true
                                    Clear-Host; Export-Gist -Name $list[$sel].Name
                                    $idx = script:LoadIdx
                                    Read-Host "`n [Press Enter to return]"
                                    [Console]::CursorVisible = $false; Clear-Host
                                }
                            }
                            '/' {
                                [Console]::CursorVisible = $true
                                $query = Read-Host " Search"
                                [Console]::CursorVisible = $false
                                $sel = 0; Clear-Host
                            }
                        }
                    }
                }
            } else {
                # detail mode
                switch ($vk) {
                    { $_ -in 8,27,37 } { $mode = 'list'; Clear-Host }   # Backspace / Esc / Left
                    default {
                        switch ($ch) {
                            'e' {
                                [Console]::CursorVisible = $true
                                Clear-Host; Edit-Snip -Name $list[$sel].Name
                                $idx = script:LoadIdx
                                [Console]::CursorVisible = $false; Clear-Host
                            }
                            'r' {
                                [Console]::CursorVisible = $true
                                Clear-Host; Invoke-Snip -Name $list[$sel].Name
                                Read-Host "`n [Press Enter to return]"
                                [Console]::CursorVisible = $false; Clear-Host
                            }
                            'c' { Copy-Snip -Name $list[$sel].Name }
                            'g' {
                                [Console]::CursorVisible = $true
                                Clear-Host; Export-Gist -Name $list[$sel].Name
                                $idx = script:LoadIdx
                                Read-Host "`n [Press Enter to return]"
                                [Console]::CursorVisible = $false; Clear-Host
                            }
                        }
                    }
                }
            }
        }
    } finally {
        [Console]::CursorVisible = $true
        Clear-Host
    }
}