PDU.psm1

#Requires -Version 5.1

class PDUEntry {
    [string]   $Name
    [string]   $FullPath
    [bool]     $IsDirectory
    [long]     $DiskSize
    [long]     $FileSize
    [int]      $ItemCount
    [datetime] $LastWrite
    [bool]     $HasError
    [bool]     $IsParentLink
    [PDUEntry] $Parent
    [System.Collections.Generic.List[PDUEntry]] $Children

    PDUEntry() {
        $this.Children  = [System.Collections.Generic.List[PDUEntry]]::new()
        $this.LastWrite = [datetime]::MinValue
    }
}

#region ── Scan ────────────────────────────────────────────────────────────────

function script:New-PDUEntry {
    param(
        [string]   $Name,
        [string]   $FullPath,
        [bool]     $IsDir,
        [PDUEntry] $Parent
    )
    $e            = [PDUEntry]::new()
    $e.Name       = $Name
    $e.FullPath   = $FullPath
    $e.IsDirectory = $IsDir
    $e.Parent     = $Parent
    return $e
}

function script:Invoke-Scan {
    param(
        [string]   $Path,
        [PDUEntry] $Parent,
        [ref]      $Counter
    )

    $entry = New-PDUEntry -Name (Split-Path $Path -Leaf) -FullPath $Path -IsDir $true -Parent $Parent

    try {
        $di = Get-Item -LiteralPath $Path -Force -ErrorAction Stop
        $entry.LastWrite = $di.LastWriteTime
    } catch { $entry.HasError = $true }

    try {
        $items = Get-ChildItem -LiteralPath $Path -Force -ErrorAction Stop
    } catch {
        $entry.HasError = $true
        return $entry
    }

    foreach ($item in $items) {
        $Counter.Value++
        if ($Counter.Value % 200 -eq 0) {
            [Console]::SetCursorPosition(0, 1)
            [Console]::ForegroundColor = [ConsoleColor]::Gray
            $msg = (' Scanning: {0} items found ...' -f $Counter.Value).PadRight([Console]::WindowWidth - 1)
            [Console]::Write($msg)
            [Console]::ResetColor()
        }

        if ($item.PSIsContainer) {
            $child = Invoke-Scan -Path $item.FullName -Parent $entry -Counter $Counter
        } else {
            $child            = New-PDUEntry -Name $item.Name -FullPath $item.FullName -IsDir $false -Parent $entry
            $child.LastWrite  = $item.LastWriteTime
            $child.ItemCount  = 1
            try {
                $child.FileSize = $item.Length
                # Estimate allocation in 4 KiB clusters; 0-byte files use 0
                if ($item.Length -gt 0) {
                    $child.DiskSize = [Math]::Ceiling($item.Length / 4096) * 4096
                }
            } catch { $child.HasError = $true }
        }

        $entry.Children.Add($child)
        $entry.DiskSize  += $child.DiskSize
        $entry.FileSize  += $child.FileSize
        $entry.ItemCount += $child.ItemCount
    }

    return $entry
}

#endregion

#region ── Formatting ──────────────────────────────────────────────────────────

function script:Format-Size {
    param([long]$Bytes)
    $units = @(' B','KiB','MiB','GiB','TiB','PiB')
    $val   = [double]$Bytes
    $idx   = 0
    while ($val -ge 1024.0 -and $idx -lt ($units.Count - 1)) { $val /= 1024.0; $idx++ }
    if ($idx -eq 0) { return '{0,6} {1}' -f [int]$val, $units[$idx] }
    return '{0,6:F1} {1}' -f $val, $units[$idx]
}

function script:Format-SizeShort {
    param([long]$Bytes)
    $units = @('B','K','M','G','T','P')
    $val   = [double]$Bytes
    $idx   = 0
    while ($val -ge 1024.0 -and $idx -lt ($units.Count - 1)) { $val /= 1024.0; $idx++ }
    if ($idx -eq 0) { return '{0,4} {1}' -f [int]$val, $units[$idx] }
    return '{0,4:F1}{1}' -f $val, $units[$idx]
}

#endregion

#region ── State ───────────────────────────────────────────────────────────────

$script:S = $null  # global TUI state

function script:Get-Sorted {
    param([PDUEntry]$Dir)
    $s    = $script:S
    $list = $Dir.Children | Where-Object { -not $_.IsParentLink }
    switch ($s.SortBy) {
        'name'  { return @($list | Sort-Object Name) }
        'asize' { return @($list | Sort-Object FileSize -Descending) }
        'count' { return @($list | Sort-Object ItemCount -Descending) }
        default { return @($list | Sort-Object DiskSize -Descending) }
    }
}

function script:Enter-Dir {
    param([PDUEntry]$Dir)
    $s = $script:S

    $children = Get-Sorted -Dir $Dir
    $maxSize  = 0
    foreach ($c in $children) { if ($c.DiskSize -gt $maxSize) { $maxSize = $c.DiskSize } }

    $entries = [System.Collections.Generic.List[PDUEntry]]::new()

    if ($null -ne $Dir.Parent) {
        $up              = [PDUEntry]::new()
        $up.Name         = '..'
        $up.FullPath     = $Dir.Parent.FullPath
        $up.IsDirectory  = $true
        $up.IsParentLink = $true
        $up.DiskSize     = $Dir.Parent.DiskSize
        $up.FileSize     = $Dir.Parent.FileSize
        $up.ItemCount    = $Dir.Parent.ItemCount
        $up.LastWrite    = $Dir.Parent.LastWrite
        $up.Parent       = $Dir.Parent.Parent
        $entries.Add($up)
    }

    foreach ($c in $children) { $entries.Add($c) }

    $s.CurrentDir    = $Dir
    $s.Entries       = $entries
    $s.SelectedIndex = 0
    $s.TopIndex      = 0
    $s.MaxSize       = $maxSize
}

#endregion

#region ── Rendering ───────────────────────────────────────────────────────────

$GRAPH_W = 10  # chars in the bar

function script:Draw-Screen {
    $s     = $script:S
    $W     = [Console]::WindowWidth
    $H     = [Console]::WindowHeight
    $bodyH = $H - 3   # 1 header + 2 footer

    # Keep selected in viewport
    if ($s.SelectedIndex -lt $s.TopIndex) {
        $s.TopIndex = $s.SelectedIndex
    }
    if ($s.SelectedIndex -ge ($s.TopIndex + $bodyH)) {
        $s.TopIndex = $s.SelectedIndex - $bodyH + 1
    }

    # ── Header ──────────────────────────────────────────────────────────────
    [Console]::SetCursorPosition(0, 0)
    [Console]::BackgroundColor = [ConsoleColor]::DarkBlue
    [Console]::ForegroundColor = [ConsoleColor]::White
    $pathStr = $s.CurrentDir.FullPath
    $hdr = ' PDU {0}' -f $pathStr
    if ($hdr.Length -gt $W) { $hdr = ' PDU ...{0}' -f $pathStr.Substring($pathStr.Length - ($W - 10)) }
    [Console]::Write($hdr.PadRight($W).Substring(0, $W))

    # ── Body ────────────────────────────────────────────────────────────────
    # Layout: [bar ] size /Name
    # 10 chars 6+4 rest
    $barCol  = 1
    $sizeCol = $barCol + $GRAPH_W + 3   # "[" + bar + "] "
    $typeCol = $sizeCol + 7             # "NNN.N X" = 7 chars
    $nameCol = $typeCol + 2             # " /" or " "
    $nameW   = [Math]::Max(8, $W - $nameCol - 1)

    for ($row = 0; $row -lt $bodyH; $row++) {
        $idx = $s.TopIndex + $row
        [Console]::SetCursorPosition(0, $row + 1)
        [Console]::ResetColor()

        if ($idx -ge $s.Entries.Count) {
            [Console]::Write(' ' * $W)
            continue
        }

        $e   = $s.Entries[$idx]
        $sel = ($idx -eq $s.SelectedIndex)

        if ($sel) {
            [Console]::BackgroundColor = [ConsoleColor]::DarkCyan
            [Console]::ForegroundColor = [ConsoleColor]::White
        } elseif ($e.IsParentLink) {
            [Console]::ForegroundColor = [ConsoleColor]::DarkYellow
        } elseif ($e.IsDirectory) {
            [Console]::ForegroundColor = [ConsoleColor]::Cyan
        } elseif ($e.HasError) {
            [Console]::ForegroundColor = [ConsoleColor]::Red
        } else {
            [Console]::ForegroundColor = [ConsoleColor]::Gray
        }

        # Bar
        $pct    = if ($s.MaxSize -gt 0) { [Math]::Min(1.0, [double]$e.DiskSize / $s.MaxSize) } else { 0.0 }
        $filled = [Math]::Round($pct * $GRAPH_W)
        $bar    = '[' + ('#' * $filled) + ('.' * ($GRAPH_W - $filled)) + ']'

        # Size
        $szStr = Format-SizeShort -Bytes $e.DiskSize

        # Type flag
        if ($e.IsParentLink)      { $flag = '/^' }
        elseif ($e.IsDirectory)   { $flag = '/ ' }
        elseif ($e.HasError)      { $flag = '! ' }
        else                      { $flag = ' ' }

        # Name
        $nm = $e.Name
        if ($nm.Length -gt $nameW) { $nm = $nm.Substring(0, $nameW - 1) + '>' }

        $line = ' {0} {1} {2}{3}' -f $bar, $szStr, $flag, $nm
        if ($line.Length -lt $W) { $line = $line.PadRight($W) }
        [Console]::Write($line.Substring(0, $W))
    }

    [Console]::ResetColor()

    # ── Separator ───────────────────────────────────────────────────────────
    [Console]::SetCursorPosition(0, $H - 2)
    [Console]::BackgroundColor = [ConsoleColor]::DarkGray
    [Console]::ForegroundColor = [ConsoleColor]::White

    $sortLabel = switch ($s.SortBy) {
        'name'  { 'Name' }; 'asize' { 'Apparent' }; 'count' { 'Count' }; default { 'Size' }
    }
    $info = ' Sort:{0} Total:{1} Items:{2} ' -f $sortLabel, (Format-SizeShort $s.CurrentDir.DiskSize), $s.CurrentDir.ItemCount
    [Console]::Write($info.PadRight($W).Substring(0, $W))

    # ── Key hints ───────────────────────────────────────────────────────────
    [Console]::SetCursorPosition(0, $H - 1)
    [Console]::BackgroundColor = [ConsoleColor]::Black
    [Console]::ForegroundColor = [ConsoleColor]::DarkGray
    $hints = ' n/s/a/c:Sort Enter/Left:Nav d:Del i:Info r:Rescan ?:Help q:Quit'
    [Console]::Write($hints.PadRight($W).Substring(0, $W))

    [Console]::ResetColor()
}

#endregion

#region ── Popups ──────────────────────────────────────────────────────────────

function script:Show-Popup {
    param([string[]]$Lines, [ConsoleColor]$BgColor = [ConsoleColor]::DarkBlue)
    $W     = [Console]::WindowWidth
    $H     = [Console]::WindowHeight
    $boxW  = [Math]::Min(62, $W - 4)
    $boxH  = $Lines.Count + 2
    $boxX  = [Math]::Floor(($W - $boxW) / 2)
    $boxY  = [Math]::Floor(($H - $boxH) / 2)
    $blank = ' ' * $boxW

    [Console]::BackgroundColor = $BgColor
    [Console]::ForegroundColor = [ConsoleColor]::White
    for ($r = 0; $r -lt $boxH; $r++) {
        [Console]::SetCursorPosition($boxX, $boxY + $r)
        [Console]::Write($blank)
    }
    for ($i = 0; $i -lt $Lines.Count; $i++) {
        [Console]::SetCursorPosition($boxX + 2, $boxY + 1 + $i)
        $txt = $Lines[$i]
        if ($txt.Length -gt ($boxW - 4)) { $txt = $txt.Substring(0, $boxW - 4) }
        [Console]::Write($txt)
    }
    [Console]::ResetColor()
}

function script:Show-Help {
    Show-Popup -Lines @(
        'PDU - PowerShell Disk Usage (NCDU-style)',
        '',
        'Navigation',
        ' Up / k Move up',
        ' Down / j Move down',
        ' PgUp / PgDn Page up / down',
        ' Home / End First / last item',
        ' Enter / Right Open directory',
        ' Left / Bksp Go to parent',
        '',
        'Sorting',
        ' s Disk size (default)',
        ' a Apparent (file) size',
        ' n Name',
        ' c Item count',
        '',
        'Actions',
        ' d Delete selected item',
        ' i Item info',
        ' r Rescan current directory',
        ' q Quit',
        '',
        'Press any key to close'
    )
    $null = [Console]::ReadKey($true)
}

function script:Show-Info {
    $s = $script:S
    if ($s.SelectedIndex -lt 0 -or $s.SelectedIndex -ge $s.Entries.Count) { return }
    $e = $s.Entries[$s.SelectedIndex]

    $typeStr = if ($e.IsDirectory) { 'Directory' } else { 'File' }
    $mtStr   = $e.LastWrite.ToString('yyyy-MM-dd HH:mm:ss')
    $errStr  = if ($e.HasError) { ' [access error]' } else { '' }

    Show-Popup -Lines @(
        'Item Info',
        '',
        ('Name : {0}' -f $e.Name),
        ('Type : {0}{1}' -f $typeStr, $errStr),
        ('Disk : {0}' -f (Format-Size -Bytes $e.DiskSize)),
        ('Size : {0}' -f (Format-Size -Bytes $e.FileSize)),
        ('Items : {0}' -f $e.ItemCount),
        ('Mtime : {0}' -f $mtStr),
        ('Path : {0}' -f $e.FullPath),
        '',
        'Press any key to close'
    )
    $null = [Console]::ReadKey($true)
}

function script:Confirm-Delete {
    param([PDUEntry]$Entry)
    $label = if ($Entry.IsDirectory) { 'directory' } else { 'file' }
    Show-Popup -BgColor ([ConsoleColor]::DarkRed) -Lines @(
        'Delete {0}?' -f $label,
        '',
        (' {0}' -f $Entry.FullPath),
        '',
        ' WARNING: this cannot be undone.',
        '',
        ' Press Y to confirm, any other key to cancel'
    )
    $k = [Console]::ReadKey($true)
    return ($k.KeyChar -eq 'y' -or $k.KeyChar -eq 'Y')
}

function script:Show-Error {
    param([string]$Message)
    Show-Popup -BgColor ([ConsoleColor]::DarkRed) -Lines @(
        'Error', '', $Message, '', 'Press any key to dismiss'
    )
    $null = [Console]::ReadKey($true)
}

#endregion

#region ── Actions ─────────────────────────────────────────────────────────────

function script:Do-Delete {
    $s = $script:S
    if ($s.SelectedIndex -lt 0 -or $s.SelectedIndex -ge $s.Entries.Count) { return }
    $sel = $s.Entries[$s.SelectedIndex]
    if ($sel.IsParentLink) { return }

    if (-not (Confirm-Delete -Entry $sel)) { return }

    try {
        Remove-Item -LiteralPath $sel.FullPath -Recurse -Force -ErrorAction Stop

        # Remove from in-memory tree
        $parent = $s.CurrentDir
        for ($j = 0; $j -lt $parent.Children.Count; $j++) {
            if ($parent.Children[$j].FullPath -eq $sel.FullPath) {
                # Walk size back up the tree
                $ancestor = $parent
                while ($null -ne $ancestor) {
                    $ancestor.DiskSize  -= $sel.DiskSize
                    $ancestor.FileSize  -= $sel.FileSize
                    $ancestor.ItemCount -= $sel.ItemCount
                    $ancestor = $ancestor.Parent
                }
                $parent.Children.RemoveAt($j)
                break
            }
        }
        Enter-Dir -Dir $s.CurrentDir
        if ($s.SelectedIndex -ge $s.Entries.Count) {
            $s.SelectedIndex = [Math]::Max(0, $s.Entries.Count - 1)
        }
    } catch {
        Show-Error -Message $_.Exception.Message
    }
}

function script:Do-Rescan {
    $s    = $script:S
    $path = $s.CurrentDir.FullPath

    # Preserve selection
    $prevName = if ($s.SelectedIndex -lt $s.Entries.Count) { $s.Entries[$s.SelectedIndex].Name } else { '' }

    [Console]::ResetColor()
    [Console]::Clear()
    [Console]::ForegroundColor = [ConsoleColor]::Yellow
    [Console]::SetCursorPosition(0, 0)
    [Console]::Write('Rescanning...')
    [Console]::ResetColor()

    $ctr  = [ref]0
    $fresh = Invoke-Scan -Path $path -Parent $s.CurrentDir.Parent -Counter $ctr

    # Splice back into parent
    if ($null -ne $s.CurrentDir.Parent) {
        $p = $s.CurrentDir.Parent
        for ($j = 0; $j -lt $p.Children.Count; $j++) {
            if ($p.Children[$j].FullPath -eq $path) {
                # Adjust ancestor sizes
                $diff = $fresh.DiskSize - $p.Children[$j].DiskSize
                $diffF = $fresh.FileSize - $p.Children[$j].FileSize
                $diffI = $fresh.ItemCount - $p.Children[$j].ItemCount
                $ancestor = $p
                while ($null -ne $ancestor) {
                    $ancestor.DiskSize  += $diff
                    $ancestor.FileSize  += $diffF
                    $ancestor.ItemCount += $diffI
                    $ancestor = $ancestor.Parent
                }
                $p.Children[$j] = $fresh
                break
            }
        }
    } else {
        $s.Root = $fresh
    }

    Enter-Dir -Dir $fresh

    if ($prevName) {
        for ($i = 0; $i -lt $s.Entries.Count; $i++) {
            if ($s.Entries[$i].Name -eq $prevName) { $s.SelectedIndex = $i; break }
        }
    }
}

#endregion

#region ── Main ────────────────────────────────────────────────────────────────

function Start-PDU {
    <#
    .SYNOPSIS
    PowerShell Disk Usage — interactive TUI disk usage browser (NCDU-style).

    .PARAMETER Path
    Root directory to scan. Defaults to the current directory.

    .EXAMPLE
    pdu
    pdu C:\Users
    Start-PDU D:\Projects
    #>

    [CmdletBinding()]
    param(
        [Parameter(Position = 0)]
        [string]$Path = (Get-Location).Path
    )

    $resolved = Resolve-Path -LiteralPath $Path -ErrorAction Stop
    $rootPath = $resolved.ProviderPath

    # ── Save terminal state ─────────────────────────────────────────────────
    $savedTitle  = $Host.UI.RawUI.WindowTitle
    $savedFg     = [Console]::ForegroundColor
    $savedBg     = [Console]::BackgroundColor
    $savedCursor = [Console]::CursorVisible

    try {
        [Console]::CursorVisible = $false
        [Console]::Clear()
        [Console]::ForegroundColor = [ConsoleColor]::Yellow
        [Console]::SetCursorPosition(0, 0)
        [Console]::Write('PDU: Scanning {0} ...' -f $rootPath)
        [Console]::ResetColor()

        $ctr  = [ref]0
        $root = Invoke-Scan -Path $rootPath -Parent $null -Counter $ctr

        if ($root.Name -eq '') { $root.Name = $rootPath }

        $Host.UI.RawUI.WindowTitle = 'PDU - PowerShell Disk Usage'

        $script:S = [pscustomobject]@{
            Root          = $root
            CurrentDir    = $root
            Entries       = [System.Collections.Generic.List[PDUEntry]]::new()
            SelectedIndex = 0
            TopIndex      = 0
            MaxSize       = 0L
            SortBy        = 'size'
        }

        Enter-Dir -Dir $root

        # ── Event loop ──────────────────────────────────────────────────────
        [Console]::Clear()
        $running = $true
        while ($running) {
            Draw-Screen

            $k = [Console]::ReadKey($true)
            $s = $script:S

            switch ($k.Key) {
                'UpArrow'   { if ($s.SelectedIndex -gt 0) { $s.SelectedIndex-- } }
                'DownArrow' { if ($s.SelectedIndex -lt ($s.Entries.Count - 1)) { $s.SelectedIndex++ } }
                'PageUp' {
                    $ph = [Console]::WindowHeight - 3
                    $s.SelectedIndex = [Math]::Max(0, $s.SelectedIndex - $ph)
                }
                'PageDown' {
                    $ph = [Console]::WindowHeight - 3
                    $s.SelectedIndex = [Math]::Min($s.Entries.Count - 1, $s.SelectedIndex + $ph)
                }
                'Home' { $s.SelectedIndex = 0 }
                'End'  { $s.SelectedIndex = [Math]::Max(0, $s.Entries.Count - 1) }
                { $_ -in 'Enter','RightArrow' } {
                    if ($s.SelectedIndex -ge 0 -and $s.SelectedIndex -lt $s.Entries.Count) {
                        $sel = $s.Entries[$s.SelectedIndex]
                        if ($sel.IsParentLink -and $null -ne $sel.Parent) {
                            $prev = $s.CurrentDir
                            Enter-Dir -Dir $sel.Parent
                            for ($i = 0; $i -lt $s.Entries.Count; $i++) {
                                if ($s.Entries[$i].FullPath -eq $prev.FullPath) { $s.SelectedIndex = $i; break }
                            }
                        } elseif ($sel.IsDirectory -and -not $sel.IsParentLink) {
                            Enter-Dir -Dir $sel
                        }
                    }
                }
                { $_ -in 'LeftArrow','Backspace' } {
                    if ($null -ne $s.CurrentDir.Parent) {
                        $prev = $s.CurrentDir
                        Enter-Dir -Dir $s.CurrentDir.Parent
                        for ($i = 0; $i -lt $s.Entries.Count; $i++) {
                            if ($s.Entries[$i].FullPath -eq $prev.FullPath) { $s.SelectedIndex = $i; break }
                        }
                    }
                }
                default {
                    switch -CaseSensitive ($k.KeyChar) {
                        'q' { $running = $false }
                        'Q' { $running = $false }
                        'j' { if ($s.SelectedIndex -lt ($s.Entries.Count - 1)) { $s.SelectedIndex++ } }
                        'k' { if ($s.SelectedIndex -gt 0) { $s.SelectedIndex-- } }
                        's' { $s.SortBy = 'size';  Enter-Dir -Dir $s.CurrentDir }
                        'a' { $s.SortBy = 'asize'; Enter-Dir -Dir $s.CurrentDir }
                        'n' { $s.SortBy = 'name';  Enter-Dir -Dir $s.CurrentDir }
                        'c' { $s.SortBy = 'count'; Enter-Dir -Dir $s.CurrentDir }
                        'd' { Do-Delete }
                        'i' { Show-Info }
                        'r' { Do-Rescan }
                        '?' { Show-Help }
                    }
                }
            }
        }
    } finally {
        [Console]::ResetColor()
        [Console]::Clear()
        [Console]::CursorVisible  = $savedCursor
        [Console]::ForegroundColor = $savedFg
        [Console]::BackgroundColor = $savedBg
        $Host.UI.RawUI.WindowTitle = $savedTitle
        $script:S = $null
    }
}

Set-Alias -Name pdu -Value Start-PDU
Export-ModuleMember -Function Start-PDU -Alias pdu