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 |