bin/analyze.ps1
|
# WinMole - Interactive Disk Space Analyzer # Arrow-key navigation, size visualization, open/delete actions param( [string]$Path = '', [switch]$Json ) $ErrorActionPreference = 'SilentlyContinue' . "$PSScriptRoot\..\lib\core.ps1" . "$PSScriptRoot\..\lib\ui.ps1" # ── Resolve start path ──────────────────────────────────────────────────────── if (-not $Path) { $Path = $env:USERPROFILE } $Path = $Path.TrimEnd('\','/') if (-not (Test-Path $Path)) { Write-Err "Path not found: $Path" exit 1 } # ── Scan top-level entries in a directory ───────────────────────────────────── function Get-DirEntries { param([string]$DirPath) $entries = [System.Collections.Generic.List[object]]::new() try { $di = [System.IO.DirectoryInfo]::new($DirPath) foreach ($child in $di.EnumerateFileSystemInfos()) { $size = [long]0 $isDir = $child -is [System.IO.DirectoryInfo] if ($isDir) { $size = Get-DirectorySize -Path $child.FullName } else { $size = $child.Length } $age = '' try { $days = (New-TimeSpan -Start $child.LastWriteTime -End (Get-Date)).Days if ($days -gt 365) { $age = '>1yr' } elseif ($days -gt 180) { $age = '>6mo' } } catch {} $entries.Add([pscustomobject]@{ Name = $child.Name Path = $child.FullName Size = $size IsDir = $isDir Age = $age }) } } catch {} return ($entries | Sort-Object Size -Descending) } # ── JSON output mode ────────────────────────────────────────────────────────── if ($Json) { $entries = Get-DirEntries -DirPath $Path $total = ($entries | Measure-Object -Property Size -Sum).Sum $large = @($entries | Where-Object { $_.Size -gt 100MB } | Select-Object Name, Path, Size, IsDir) $output = [pscustomobject]@{ path = $Path entries = $entries | Select-Object Name, Path, Size, IsDir large_files = $large total_size = $total total_files = $entries.Count } $output | ConvertTo-Json -Depth 3 return } # ── Interactive TUI analyzer ────────────────────────────────────────────────── $stack = [System.Collections.Generic.Stack[string]]::new() $curPath = $Path $entries = $null $cursor = 0 $offset = 0 $maxVis = [Math]::Min(20, $Host.UI.RawUI.WindowSize.Height - 10) $largeMode = $false $startupGuardUntil = (Get-Date).AddSeconds(2) $hasUserInteraction = $false function Reload-Entries { $script:entries = @(Get-DirEntries -DirPath $curPath) $script:cursor = 0 $script:offset = 0 } Reload-Entries Hide-Cursor [Console]::TreatControlCAsInput = $true $quitRequested = $false $cancelHandler = [System.ConsoleCancelEventHandler]{ param($sender, $eventArgs) $eventArgs.Cancel = $true $script:quitRequested = $true } [Console]::add_CancelKeyPress($cancelHandler) try { Clear-PendingKeys } catch {} try { while ($true) { if ($quitRequested) { break } if (-not $entries -or $entries.Count -eq 0) { $entries = @() } $totalSize = ($entries | Measure-Object -Property Size -Sum).Sum $visibleEntries = if ($largeMode) { @($entries | Where-Object { $_.Size -gt 100MB }) } else { $entries } # ── Render ──────────────────────────────────────────────────────────── [Console]::Clear() $relPath = $curPath -replace [regex]::Escape($env:USERPROFILE), '~' Write-Host "" Write-SpectreHost " [bold deepskyblue1]WinMole Analyze[/] [grey]$(Esc $relPath)[/] [gold1]Total: $(Format-Bytes $totalSize)[/]" Write-Host "" if ($largeMode) { Write-SpectreHost " [gold1]Large files (>100 MB) — press L to return[/]" Write-Host "" } # ── Entry list ──────────────────────────────────────────────────────── $end = [Math]::Min($offset + $maxVis, $visibleEntries.Count) for ($i = $offset; $i -lt $end; $i++) { $e = $visibleEntries[$i] $isCur = ($i -eq $cursor) $pointer = if ($isCur) { '[deepskyblue1]▶[/]' } else { ' ' } $pct = if ($totalSize -gt 0) { $e.Size / $totalSize * 100 } else { 0 } $barColor = if ($pct -gt 50) { 'red' } elseif ($pct -gt 25) { 'yellow' } else { 'green' } $bar = Get-ProgressBar -Pct $pct -Width 18 -Color $barColor $icon = if ($e.IsDir) { 'DIR' } else { 'FILE' } $ageStr = if ($e.Age) { " [grey]$($e.Age)[/]" } else { '' } $rank = '{0,3}.' -f ($i + 1) $sizePad = (Format-Bytes -Bytes $e.Size).PadLeft(8) $namePad = $e.Name if ($namePad.Length -gt 30) { $namePad = $namePad.Substring(0,27) + '...' } $namePad = $namePad.PadRight(30) Write-SpectreHost (Format-AnalyzeEntryLine -Pointer $pointer -Rank $rank -Bar $bar -Percent $pct -Icon $icon -Name $namePad -SizeText $sizePad -AgeText $ageStr) } # ── Footer ──────────────────────────────────────────────────────────── Write-Host "" Write-SpectreHost " [grey]Up/Down Navigate Right/Enter Open dir Left Back O Open F Reveal Del Delete L Large files Q Quit[/]" Write-Host "" # ── Input ───────────────────────────────────────────────────────────── $key = Read-Key if (-not $key) { continue } if (-not $hasUserInteraction -and (Get-Date) -lt $startupGuardUntil -and $key -in @('Enter', 'Escape')) { continue } switch ($key) { 'Up' { $hasUserInteraction = $true if ($cursor -gt 0) { $cursor-- if ($cursor -lt $offset) { $offset = $cursor } } } 'Down' { $hasUserInteraction = $true $maxCursor = $visibleEntries.Count - 1 if ($cursor -lt $maxCursor) { $cursor++ if ($cursor -ge $offset + $maxVis) { $offset++ } } } 'Right' { $hasUserInteraction = $true if ($cursor -lt $visibleEntries.Count) { $sel = $visibleEntries[$cursor] if ($sel.IsDir) { $stack.Push($curPath) $curPath = $sel.Path Reload-Entries $largeMode = $false } } } 'Enter' { $hasUserInteraction = $true if ($cursor -lt $visibleEntries.Count) { $sel = $visibleEntries[$cursor] if ($sel.IsDir) { $stack.Push($curPath) $curPath = $sel.Path Reload-Entries $largeMode = $false } } } 'Left' { $hasUserInteraction = $true if ($stack.Count -gt 0) { $curPath = $stack.Pop() Reload-Entries $largeMode = $false } } 'o' { $hasUserInteraction = $true if ($cursor -lt $visibleEntries.Count) { Start-Process -FilePath $visibleEntries[$cursor].Path -ErrorAction SilentlyContinue } } 'O' { $hasUserInteraction = $true if ($cursor -lt $visibleEntries.Count) { Start-Process -FilePath $visibleEntries[$cursor].Path -ErrorAction SilentlyContinue } } 'f' { $hasUserInteraction = $true if ($cursor -lt $visibleEntries.Count) { Start-Process -FilePath 'explorer.exe' -ArgumentList "/select,`"$($visibleEntries[$cursor].Path)`"" -ErrorAction SilentlyContinue } } 'F' { $hasUserInteraction = $true if ($cursor -lt $visibleEntries.Count) { Start-Process -FilePath 'explorer.exe' -ArgumentList "/select,`"$($visibleEntries[$cursor].Path)`"" -ErrorAction SilentlyContinue } } 'Delete' { $hasUserInteraction = $true if ($cursor -lt $visibleEntries.Count) { $sel = $visibleEntries[$cursor] Show-Cursor Write-Host "" Write-Host " Delete '$($sel.Name)' ($(Format-Bytes $sel.Size))? " -ForegroundColor Red -NoNewline Write-Host "(y/N) " -ForegroundColor DarkGray -NoNewline $conf = Read-ConsoleKeyInfo Hide-Cursor if ((Get-KeyCharacter -KeyInfo $conf) -in @('y','Y')) { Remove-Item -LiteralPath $sel.Path -Recurse -Force -ErrorAction SilentlyContinue Write-OpLog "Analyze-delete: $($sel.Path) ($(Format-Bytes $sel.Size))" Reload-Entries $largeMode = $false } } } 'l' { $hasUserInteraction = $true $largeMode = -not $largeMode $cursor = 0 $offset = 0 } 'L' { $hasUserInteraction = $true $largeMode = -not $largeMode $cursor = 0 $offset = 0 } 'Quit' { return } 'Escape' { if ($hasUserInteraction) { return } } } } } finally { [Console]::remove_CancelKeyPress($cancelHandler) Show-Cursor [Console]::TreatControlCAsInput = $false } Write-Host "" |