PDU.psm1

#Requires -Version 5.1

#region ── Native scanner ──────────────────────────────────────────────────────

# The tree is built in C# rather than PowerShell. Get-ChildItem wraps every item
# in a PSObject with ETS members, which dominates the cost of a large scan: on a
# 60k-item tree that path measured 7.25s against 0.42s for FindFirstFileExW.
# PDU.Entry is the node type the whole TUI works with directly -- converting a
# native tree into PowerShell objects afterwards would cost back everything the
# native walk saves.

if (-not ('PDU.Scanner' -as [type])) {
Add-Type -Language CSharp -TypeDefinition @'
using System;
using System.Collections.Generic;
using System.Collections.Concurrent;
using System.IO;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
 
namespace PDU {
 
  public class Entry {
    public string Name;
    public string FullPath;
    public bool IsDirectory;
    public bool IsReparse;
    public bool IsParentLink;
    public bool HasError;
    public long DiskSize;
    public long FileSize;
    public int ItemCount;
    public long LastWriteRaw;
    public Entry Parent;
    public List<Entry> Children;
 
    public Entry() { Children = new List<Entry>(); }
 
    // Kept as a raw FILETIME and converted only when read. Converting during the
    // walk would cost a DateTime construction per file for a value the browser
    // only ever displays one of at a time.
    public DateTime LastWrite {
      get {
        if (LastWriteRaw <= 0) return DateTime.MinValue;
        try { return DateTime.FromFileTime(LastWriteRaw); }
        catch { return DateTime.MinValue; }
      }
      set { LastWriteRaw = (value == DateTime.MinValue) ? 0 : value.ToFileTime(); }
    }
  }
 
  public static class Scanner {
 
    [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
    struct WIN32_FIND_DATA {
      public uint dwFileAttributes;
      public System.Runtime.InteropServices.ComTypes.FILETIME ftCreationTime;
      public System.Runtime.InteropServices.ComTypes.FILETIME ftLastAccessTime;
      public System.Runtime.InteropServices.ComTypes.FILETIME ftLastWriteTime;
      public uint nFileSizeHigh;
      public uint nFileSizeLow;
      public uint dwReserved0;
      public uint dwReserved1;
      [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] public string cFileName;
      [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 14)] public string cAlternateFileName;
    }
 
    [StructLayout(LayoutKind.Sequential)]
    struct WIN32_FILE_ATTRIBUTE_DATA {
      public uint dwFileAttributes;
      public System.Runtime.InteropServices.ComTypes.FILETIME ftCreationTime;
      public System.Runtime.InteropServices.ComTypes.FILETIME ftLastAccessTime;
      public System.Runtime.InteropServices.ComTypes.FILETIME ftLastWriteTime;
      public uint nFileSizeHigh;
      public uint nFileSizeLow;
    }
 
    [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
    static extern IntPtr FindFirstFileExW(string lpFileName, int fInfoLevelId,
      out WIN32_FIND_DATA lpFindFileData, int fSearchOp, IntPtr lpSearchFilter,
      int dwAdditionalFlags);
 
    [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
    static extern bool FindNextFileW(IntPtr hFindFile, out WIN32_FIND_DATA lpFindFileData);
 
    [DllImport("kernel32.dll", SetLastError = true)]
    static extern bool FindClose(IntPtr hFindFile);
 
    [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
    static extern bool GetFileAttributesExW(string lpFileName, int fInfoLevelId,
      out WIN32_FILE_ATTRIBUTE_DATA lpFileInformation);
 
    [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
    static extern bool GetDiskFreeSpaceW(string lpRootPathName,
      out uint lpSectorsPerCluster, out uint lpBytesPerSector,
      out uint lpNumberOfFreeClusters, out uint lpTotalNumberOfClusters);
 
    const int FindExInfoBasic = 1; // skips cAlternateFileName
    const int FindExSearchNameMatch = 0;
    const int FIND_FIRST_EX_LARGE_FETCH = 2;
    const int GetFileExInfoStandard = 0;
 
    static readonly IntPtr INVALID_HANDLE = new IntPtr(-1);
 
    const uint FILE_ATTRIBUTE_DIRECTORY = 0x00000010;
    const uint FILE_ATTRIBUTE_REPARSE_POINT = 0x00000400;
 
    const char SEP = '\\';
    const string SEPS = @"\";
    const string LONG_PREFIX = @"\\?\";
    const string UNC_PREFIX = @"\\?\UNC\";
    const string UNC_START = @"\\";
 
    // Cluster size of the volume being scanned; refreshed at the start of Scan.
    public static long ClusterSize = 4096;
 
    // Junctions and symlinks are enumerated but not descended into, so that
    // C:\ProgramData is not also counted under C:\Users\All Users and the
    // AppData self-junctions cannot send the walk into a loop.
    public static bool FollowReparse = false;
 
    public static int MaxThreads = Environment.ProcessorCount;
 
    static long _itemsSeen;
    static string _currentPath = "";
 
    public static long ItemsSeen { get { return Interlocked.Read(ref _itemsSeen); } }
    public static string CurrentPath { get { return _currentPath; } }
 
    // \\?\ bypasses MAX_PATH, so deeply nested trees enumerate instead of erroring.
    static string Prefixed(string p) {
      if (p.StartsWith(LONG_PREFIX)) return p;
      if (p.StartsWith(UNC_START)) return UNC_PREFIX + p.Substring(2);
      return LONG_PREFIX + p;
    }
 
    static long FileTimeToLong(System.Runtime.InteropServices.ComTypes.FILETIME ft) {
      return ((long)ft.dwHighDateTime << 32) | (uint)ft.dwLowDateTime;
    }
 
    static long Allocated(long length) {
      if (length <= 0) return 0;
      long cs = ClusterSize > 0 ? ClusterSize : 4096;
      return ((length + cs - 1) / cs) * cs;
    }
 
    static void LoadClusterSize(string path) {
      ClusterSize = 4096;
      try {
        string root = Path.GetPathRoot(path);
        if (string.IsNullOrEmpty(root)) return;
        if (!root.EndsWith(SEPS)) root += SEPS;
        uint spc, bps, free, total;
        if (GetDiskFreeSpaceW(root, out spc, out bps, out free, out total)) {
          long cs = (long)spc * bps;
          if (cs > 0) ClusterSize = cs;
        }
      } catch { }
    }
 
    // Enumerates one directory. The calling thread owns node exclusively, so
    // node.Children needs no lock; only the shared frontier is concurrent.
    static List<Entry> ScanOne(Entry node) {
      var subdirs = new List<Entry>();
      string basePath = node.FullPath.TrimEnd(SEP);
      string spec = Prefixed(basePath) + SEPS + "*";
 
      WIN32_FIND_DATA fd;
      IntPtr h = FindFirstFileExW(spec, FindExInfoBasic, out fd, FindExSearchNameMatch,
                                  IntPtr.Zero, FIND_FIRST_EX_LARGE_FETCH);
      if (h == INVALID_HANDLE) { node.HasError = true; return subdirs; }
 
      _currentPath = node.FullPath;
      int local = 0;
      try {
        do {
          string name = fd.cFileName;
          if (name == "." || name == "..") continue;
          local++;
 
          bool isDir = (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0;
          bool isReparse = (fd.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0;
 
          var child = new Entry {
            Name = name,
            FullPath = basePath + SEPS + name,
            Parent = node,
            IsDirectory = isDir,
            IsReparse = isReparse,
            LastWriteRaw = FileTimeToLong(fd.ftLastWriteTime)
          };
 
          if (isDir) {
            if (!isReparse || FollowReparse) subdirs.Add(child);
          } else {
            long len = ((long)fd.nFileSizeHigh << 32) | fd.nFileSizeLow;
            child.FileSize = len;
            child.DiskSize = Allocated(len);
            child.ItemCount = 1;
          }
          node.Children.Add(child);
        } while (FindNextFileW(h, out fd));
      } finally {
        FindClose(h);
      }
 
      Interlocked.Add(ref _itemsSeen, local);
      return subdirs;
    }
 
    // Bottom-up roll-up, done iteratively so a deep tree cannot blow the stack.
    static void RollUp(Entry root) {
      var order = new List<Entry>();
      var stack = new Stack<Entry>();
      stack.Push(root);
      while (stack.Count > 0) {
        Entry n = stack.Pop();
        order.Add(n);
        for (int i = 0; i < n.Children.Count; i++)
          if (n.Children[i].IsDirectory) stack.Push(n.Children[i]);
      }
      for (int i = order.Count - 1; i >= 0; i--) {
        Entry n = order[i];
        long ds = 0, fs = 0; int ic = 0;
        for (int j = 0; j < n.Children.Count; j++) {
          Entry c = n.Children[j];
          ds += c.DiskSize; fs += c.FileSize; ic += c.ItemCount;
        }
        n.DiskSize = ds; n.FileSize = fs; n.ItemCount = ic;
      }
    }
 
    static Entry NewRoot(string path, Entry parent) {
      string name = Path.GetFileName(path.TrimEnd(SEP));
      if (string.IsNullOrEmpty(name)) name = path;
 
      var root = new Entry {
        Name = name, FullPath = path, Parent = parent, IsDirectory = true
      };
 
      WIN32_FILE_ATTRIBUTE_DATA ad;
      if (GetFileAttributesExW(Prefixed(path.TrimEnd(SEP)), GetFileExInfoStandard, out ad)) {
        root.LastWriteRaw = FileTimeToLong(ad.ftLastWriteTime);
        root.IsReparse = (ad.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0;
      } else {
        root.HasError = true;
      }
      return root;
    }
 
    // Level-synchronous parallel BFS. Measured on a 467k-item C:\ this runs in
    // ~5.3s against ~11.7s serial; it saturates on metadata round-trips well
    // before it saturates cores, so MaxThreads above ProcessorCount does not help.
    public static Entry Scan(string path, Entry parent) {
      Interlocked.Exchange(ref _itemsSeen, 0);
      _currentPath = path;
      LoadClusterSize(path);
 
      Entry root = NewRoot(path, parent);
 
      int threads = MaxThreads > 0 ? MaxThreads : 1;
      var opts = new ParallelOptions { MaxDegreeOfParallelism = threads };
      var current = new List<Entry> { root };
 
      while (current.Count > 0) {
        var next = new ConcurrentBag<Entry>();
        if (current.Count == 1) {
          // Nothing to spread across threads at this level.
          foreach (Entry sd in ScanOne(current[0])) next.Add(sd);
        } else {
          Parallel.ForEach(current, opts, n => {
            List<Entry> subs = ScanOne(n);
            for (int i = 0; i < subs.Count; i++) next.Add(subs[i]);
          });
        }
        current = new List<Entry>(next);
      }
 
      RollUp(root);
      _currentPath = path;
      return root;
    }
 
    // Runs the walk off the PowerShell thread so the caller can render progress.
    public static Task<Entry> ScanAsync(string path, Entry parent) {
      return Task.Factory.StartNew(
        () => Scan(path, parent), TaskCreationOptions.LongRunning);
    }
  }
}
'@

}

#endregion

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

function script:Write-ScanProgress {
    param([string]$Title)

    # No console to draw on (output redirected, or hosted): scan silently.
    try { $W = [Console]::WindowWidth } catch { return }
    if ($W -lt 20) { return }

    [Console]::SetCursorPosition(0, 0)
    [Console]::ForegroundColor = [ConsoleColor]::Yellow
    [Console]::Write($Title.PadRight($W - 1).Substring(0, $W - 1))

    $line = ' {0:N0} items ...' -f [PDU.Scanner]::ItemsSeen
    $cur  = [PDU.Scanner]::CurrentPath
    if ($cur) {
        $room = $W - 5 - $line.Length
        if ($room -gt 12) {
            if ($cur.Length -gt $room) { $cur = '...' + $cur.Substring($cur.Length - $room + 3) }
            $line = '{0} {1}' -f $line, $cur
        }
    }

    [Console]::SetCursorPosition(0, 1)
    [Console]::ForegroundColor = [ConsoleColor]::DarkGray
    [Console]::Write($line.PadRight($W - 1).Substring(0, $W - 1))
    [Console]::ResetColor()
}

function script:Invoke-Scan {
    param(
        [string]    $Path,
        [PDU.Entry] $Parent,
        [string]    $Title = 'PDU: Scanning'
    )

    # The walk runs off the PowerShell thread only so progress can be drawn while
    # it works -- ten redraws a second rather than one per 200 items found.
    $task = [PDU.Scanner]::ScanAsync($Path, $Parent)

    while (-not $task.IsCompleted) {
        Write-ScanProgress -Title ('{0} {1} ...' -f $Title, $Path)
        Start-Sleep -Milliseconds 100
    }

    if ($task.IsFaulted) { throw $task.Exception.GetBaseException() }
    return $task.Result
}

#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([PDU.Entry]$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([PDU.Entry]$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[PDU.Entry]]::new()

    if ($null -ne $Dir.Parent) {
        $up              = [PDU.Entry]::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 -and $e.IsReparse) { $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 ──────────────────────────────────────────────────────────────

# Geometry is split out so it can be tested without a console, and so that a
# popup taller or wider than the window clamps instead of throwing out of
# SetCursorPosition and taking the whole app down with it.
function script:Get-PopupLayout {
    param([int]$LineCount, [int]$W, [int]$H)

    $boxW = [Math]::Min(62, $W - 4)
    if ($boxW -lt 8) { $boxW = [Math]::Max(1, [Math]::Min(8, $W)) }

    $maxLines = [Math]::Max(1, $H - 2)
    $shown    = [Math]::Min([Math]::Max(0, $LineCount), $maxLines)
    $boxH     = [Math]::Min($H, $shown + 2)

    $boxX = [Math]::Max(0, [Math]::Floor(($W - $boxW) / 2))
    $boxY = [Math]::Max(0, [Math]::Floor(($H - $boxH) / 2))
    if (($boxX + $boxW) -gt $W) { $boxW = $W - $boxX }
    if (($boxY + $boxH) -gt $H) { $boxH = $H - $boxY }

    [pscustomobject]@{
        X = [int]$boxX; Y = [int]$boxY
        Width = [int]$boxW; Height = [int]$boxH
        VisibleLines = [int]$shown
        Truncated = ($shown -lt $LineCount)
    }
}

function script:Show-Popup {
    param([string[]]$Lines, [ConsoleColor]$BgColor = [ConsoleColor]::DarkBlue)

    try { $W = [Console]::WindowWidth; $H = [Console]::WindowHeight } catch { return }
    $lay = Get-PopupLayout -LineCount $Lines.Count -W $W -H $H
    if ($lay.Width -le 0 -or $lay.Height -le 0) { return }

    # Too short a window to show everything: keep the head and the closing line,
    # so the "press any key" instruction never disappears.
    $show = $Lines
    if ($lay.Truncated) {
        $keep = [Math]::Max(0, $lay.VisibleLines - 1)
        $show = @($Lines[0..([Math]::Max(0, $keep - 1))]) + @($Lines[-1])
        if ($lay.VisibleLines -le 1) { $show = @($Lines[-1]) }
    }

    $textW = [Math]::Max(0, $lay.Width - 4)
    $blank = ' ' * $lay.Width

    [Console]::BackgroundColor = $BgColor
    [Console]::ForegroundColor = [ConsoleColor]::White
    for ($r = 0; $r -lt $lay.Height; $r++) {
        [Console]::SetCursorPosition($lay.X, $lay.Y + $r)
        [Console]::Write($blank)
    }
    $rows = [Math]::Min($show.Count, [Math]::Max(0, $lay.Height - 2))
    for ($i = 0; $i -lt $rows; $i++) {
        [Console]::SetCursorPosition($lay.X + 2, $lay.Y + 1 + $i)
        $txt = [string]$show[$i]
        if ($txt.Length -gt $textW) { $txt = $txt.Substring(0, $textW) }
        [Console]::Write($txt)
    }
    [Console]::ResetColor()
}

function script:Show-Help {
    Show-Popup -Lines @(
        'PDU - PowerShell Disk Usage (NCDU-style)',
        '',
        'Navigation Sorting',
        ' Up / k Move up s Disk size (default)',
        ' Down / j Move down a Apparent (file) size',
        ' PgUp / PgDn Page up/dn n Name',
        ' Home / End First / last c Item count',
        ' Enter / Right Open dir',
        ' Left / Bksp Go to parent',
        '',
        'Actions Flags',
        ' d Delete selected item / Directory',
        ' i Item info /^ Parent',
        ' r Rescan directory /@ Junction (not counted)',
        ' q Quit ! Access error',
        '',
        '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 -and $e.IsReparse) { 'Junction / symlink (not followed)' }
               elseif ($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([PDU.Entry]$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]::ResetColor()
    [Console]::Clear()

    $fresh = Invoke-Scan -Path $path -Parent $s.CurrentDir.Parent -Title 'PDU: Rescanning'

    # 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()

        $root = Invoke-Scan -Path $rootPath -Parent $null

        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[PDU.Entry]]::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