From 8a37f63bc7bcf45447d977f7e8fa839de5fda031 Mon Sep 17 00:00:00 2001 From: Bhadra Date: Thu, 8 Jan 2026 16:18:26 +0530 Subject: [PATCH] feat(windows): add Windows support Phase 3 - TUI tools Add Go-based TUI tools for Windows: - cmd/analyze: Interactive disk space analyzer with Bubble Tea - cmd/status: Real-time system health monitor using gopsutil/WMI - bin/analyze.ps1: Wrapper script for analyze tool - bin/status.ps1: Wrapper script for status tool - Makefile: Build automation for Go tools - Updated README.md with Phase 3 documentation --- windows/.gitignore | 16 + windows/Makefile | 44 +++ windows/README.md | 98 ++++-- windows/bin/analyze.ps1 | 79 +++++ windows/bin/status.ps1 | 73 ++++ windows/cmd/analyze/main.go | 652 ++++++++++++++++++++++++++++++++++ windows/cmd/status/main.go | 674 ++++++++++++++++++++++++++++++++++++ 7 files changed, 1616 insertions(+), 20 deletions(-) create mode 100644 windows/.gitignore create mode 100644 windows/Makefile create mode 100644 windows/bin/analyze.ps1 create mode 100644 windows/bin/status.ps1 create mode 100644 windows/cmd/analyze/main.go create mode 100644 windows/cmd/status/main.go diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 0000000..0a24ad3 --- /dev/null +++ b/windows/.gitignore @@ -0,0 +1,16 @@ +# Windows Mole - .gitignore + +# Build artifacts +bin/*.exe + +# Go build cache +.gocache/ + +# IDE files +.idea/ +.vscode/ +*.code-workspace + +# Test artifacts +*.test +coverage.out diff --git a/windows/Makefile b/windows/Makefile new file mode 100644 index 0000000..b2904ba --- /dev/null +++ b/windows/Makefile @@ -0,0 +1,44 @@ +# Mole Windows - Makefile +# Build Go tools for Windows + +.PHONY: all build clean analyze status + +# Default target +all: build + +# Build both tools +build: analyze status + +# Build analyze tool +analyze: + @echo "Building analyze..." + @go build -o bin/analyze.exe ./cmd/analyze/ + +# Build status tool +status: + @echo "Building status..." + @go build -o bin/status.exe ./cmd/status/ + +# Clean build artifacts +clean: + @echo "Cleaning..." + @rm -f bin/analyze.exe bin/status.exe + +# Install (copy to PATH) +install: build + @echo "Installing to $(USERPROFILE)/bin..." + @mkdir -p "$(USERPROFILE)/bin" + @cp bin/analyze.exe "$(USERPROFILE)/bin/" + @cp bin/status.exe "$(USERPROFILE)/bin/" + +# Run tests +test: + @go test -v ./... + +# Format code +fmt: + @go fmt ./... + +# Vet code +vet: + @go vet ./... diff --git a/windows/README.md b/windows/README.md index 5b1108b..044fdcb 100644 --- a/windows/README.md +++ b/windows/README.md @@ -6,7 +6,7 @@ Windows support for [Mole](https://github.com/tw93/Mole) - A system maintenance - Windows 10/11 - PowerShell 5.1 or later (pre-installed on Windows 10/11) -- Optional: Go 1.24+ (for building TUI tools) +- Go 1.24+ (for building TUI tools) ## Installation @@ -48,14 +48,35 @@ mole -ShowHelp # Show version mole -Version + +# Commands +mole clean # Deep system cleanup +mole clean -DryRun # Preview cleanup without deleting +mole uninstall # Interactive app uninstaller +mole optimize # System optimization +mole purge # Clean developer artifacts +mole analyze # Disk space analyzer +mole status # System health monitor ``` +## Commands + +| Command | Description | +|---------|-------------| +| `clean` | Deep cleanup of temp files, caches, and logs | +| `uninstall` | Interactive application uninstaller | +| `optimize` | System optimization and health checks | +| `purge` | Clean project build artifacts (node_modules, etc.) | +| `analyze` | Interactive disk space analyzer (TUI) | +| `status` | Real-time system health monitor (TUI) | + ## Environment Variables | Variable | Description | |----------|-------------| | `MOLE_DRY_RUN=1` | Preview changes without making them | | `MOLE_DEBUG=1` | Enable debug output | +| `MO_ANALYZE_PATH` | Starting path for analyze tool | ## Directory Structure @@ -63,15 +84,51 @@ mole -Version windows/ ├── mole.ps1 # Main CLI entry point ├── install.ps1 # Windows installer +├── Makefile # Build automation for Go tools ├── go.mod # Go module definition ├── go.sum # Go dependencies +├── bin/ +│ ├── clean.ps1 # Deep cleanup orchestrator +│ ├── uninstall.ps1 # Interactive app uninstaller +│ ├── optimize.ps1 # System optimization +│ ├── purge.ps1 # Project artifact cleanup +│ ├── analyze.ps1 # Disk analyzer wrapper +│ └── status.ps1 # Status monitor wrapper +├── cmd/ +│ ├── analyze/ # Disk analyzer (Go TUI) +│ │ └── main.go +│ └── status/ # System status (Go TUI) +│ └── main.go └── lib/ - └── core/ - ├── base.ps1 # Core definitions and utilities - ├── common.ps1 # Common functions loader - ├── file_ops.ps1 # Safe file operations - ├── log.ps1 # Logging functions - └── ui.ps1 # Interactive UI components + ├── core/ + │ ├── base.ps1 # Core definitions and utilities + │ ├── common.ps1 # Common functions loader + │ ├── file_ops.ps1 # Safe file operations + │ ├── log.ps1 # Logging functions + │ └── ui.ps1 # Interactive UI components + └── clean/ + ├── user.ps1 # User cleanup (temp, downloads, etc.) + ├── caches.ps1 # Browser and app caches + ├── dev.ps1 # Developer tool caches + ├── apps.ps1 # Application leftovers + └── system.ps1 # System cleanup (requires admin) +``` + +## Building TUI Tools + +The analyze and status commands require Go to be installed: + +```powershell +cd windows + +# Build both tools +make build + +# Or build individually +go build -o bin/analyze.exe ./cmd/analyze/ +go build -o bin/status.exe ./cmd/status/ + +# The wrapper scripts will auto-build if Go is available ``` ## Configuration @@ -80,26 +137,27 @@ Mole stores its configuration in: - Config: `~\.config\mole\` - Cache: `~\.cache\mole\` - Whitelist: `~\.config\mole\whitelist.txt` +- Purge paths: `~\.config\mole\purge_paths.txt` -## Development +## Development Phases -### Phase 1: Core Infrastructure (Current) +### Phase 1: Core Infrastructure ✅ - [x] `install.ps1` - Windows installer - [x] `mole.ps1` - Main CLI entry point - [x] `lib/core/*` - Core utility libraries -### Phase 2: Cleanup Features (Planned) -- [ ] `bin/clean.ps1` - Deep cleanup orchestrator -- [ ] `bin/uninstall.ps1` - App removal with leftover detection -- [ ] `bin/optimize.ps1` - Cache rebuild and service refresh -- [ ] `bin/purge.ps1` - Aggressive cleanup mode -- [ ] `lib/clean/*` - Cleanup modules +### Phase 2: Cleanup Features ✅ +- [x] `bin/clean.ps1` - Deep cleanup orchestrator +- [x] `bin/uninstall.ps1` - App removal with leftover detection +- [x] `bin/optimize.ps1` - System optimization +- [x] `bin/purge.ps1` - Project artifact cleanup +- [x] `lib/clean/*` - Cleanup modules -### Phase 3: TUI Tools (Planned) -- [ ] `cmd/analyze/` - Disk usage analyzer (Go) -- [ ] `cmd/status/` - Real-time system monitor (Go) -- [ ] `bin/analyze.ps1` - Analyzer wrapper -- [ ] `bin/status.ps1` - Status wrapper +### Phase 3: TUI Tools ✅ +- [x] `cmd/analyze/` - Disk usage analyzer (Go) +- [x] `cmd/status/` - Real-time system monitor (Go) +- [x] `bin/analyze.ps1` - Analyzer wrapper +- [x] `bin/status.ps1` - Status wrapper ### Phase 4: Testing & CI (Planned) - [ ] `tests/` - Pester tests diff --git a/windows/bin/analyze.ps1 b/windows/bin/analyze.ps1 new file mode 100644 index 0000000..a792172 --- /dev/null +++ b/windows/bin/analyze.ps1 @@ -0,0 +1,79 @@ +# Mole - Analyze Command +# Disk space analyzer wrapper + +#Requires -Version 5.1 +param( + [Parameter(Position = 0)] + [string]$Path, + [switch]$ShowHelp +) + +$ErrorActionPreference = "Stop" + +# Script location +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$windowsDir = Split-Path -Parent $scriptDir +$binPath = Join-Path $windowsDir "bin\analyze.exe" + +# Help +function Show-AnalyzeHelp { + $esc = [char]27 + Write-Host "" + Write-Host "$esc[1;35mMole Analyze$esc[0m - Interactive disk space analyzer" + Write-Host "" + Write-Host "$esc[33mUsage:$esc[0m mole analyze [path]" + Write-Host "" + Write-Host "$esc[33mOptions:$esc[0m" + Write-Host " [path] Path to analyze (default: user profile)" + Write-Host " -ShowHelp Show this help message" + Write-Host "" + Write-Host "$esc[33mKeybindings:$esc[0m" + Write-Host " Up/Down Navigate entries" + Write-Host " Enter Enter directory" + Write-Host " Backspace Go back" + Write-Host " Space Multi-select" + Write-Host " d Delete selected" + Write-Host " f Toggle large files view" + Write-Host " o Open in Explorer" + Write-Host " r Refresh" + Write-Host " q Quit" + Write-Host "" +} + +if ($ShowHelp) { + Show-AnalyzeHelp + return +} + +# Check if binary exists +if (-not (Test-Path $binPath)) { + Write-Host "Building analyze tool..." -ForegroundColor Cyan + + $cmdDir = Join-Path $windowsDir "cmd\analyze" + $binDir = Join-Path $windowsDir "bin" + + if (-not (Test-Path $binDir)) { + New-Item -ItemType Directory -Path $binDir -Force | Out-Null + } + + Push-Location $windowsDir + try { + $result = & go build -o "$binPath" "./cmd/analyze/" 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Host "Failed to build analyze tool: $result" -ForegroundColor Red + Pop-Location + return + } + } + finally { + Pop-Location + } +} + +# Set path environment variable if provided +if ($Path) { + $env:MO_ANALYZE_PATH = $Path +} + +# Run the binary +& $binPath diff --git a/windows/bin/status.ps1 b/windows/bin/status.ps1 new file mode 100644 index 0000000..ed3e3c1 --- /dev/null +++ b/windows/bin/status.ps1 @@ -0,0 +1,73 @@ +# Mole - Status Command +# System status monitor wrapper + +#Requires -Version 5.1 +param( + [switch]$ShowHelp +) + +$ErrorActionPreference = "Stop" + +# Script location +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$windowsDir = Split-Path -Parent $scriptDir +$binPath = Join-Path $windowsDir "bin\status.exe" + +# Help +function Show-StatusHelp { + $esc = [char]27 + Write-Host "" + Write-Host "$esc[1;35mMole Status$esc[0m - Real-time system health monitor" + Write-Host "" + Write-Host "$esc[33mUsage:$esc[0m mole status" + Write-Host "" + Write-Host "$esc[33mOptions:$esc[0m" + Write-Host " -ShowHelp Show this help message" + Write-Host "" + Write-Host "$esc[33mDisplays:$esc[0m" + Write-Host " - System health score (0-100)" + Write-Host " - CPU usage and model" + Write-Host " - Memory and swap usage" + Write-Host " - Disk space per drive" + Write-Host " - Top processes by CPU" + Write-Host " - Network interfaces" + Write-Host "" + Write-Host "$esc[33mKeybindings:$esc[0m" + Write-Host " c Toggle mole animation" + Write-Host " r Force refresh" + Write-Host " q Quit" + Write-Host "" +} + +if ($ShowHelp) { + Show-StatusHelp + return +} + +# Check if binary exists +if (-not (Test-Path $binPath)) { + Write-Host "Building status tool..." -ForegroundColor Cyan + + $cmdDir = Join-Path $windowsDir "cmd\status" + $binDir = Join-Path $windowsDir "bin" + + if (-not (Test-Path $binDir)) { + New-Item -ItemType Directory -Path $binDir -Force | Out-Null + } + + Push-Location $windowsDir + try { + $result = & go build -o "$binPath" "./cmd/status/" 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Host "Failed to build status tool: $result" -ForegroundColor Red + Pop-Location + return + } + } + finally { + Pop-Location + } +} + +# Run the binary +& $binPath diff --git a/windows/cmd/analyze/main.go b/windows/cmd/analyze/main.go new file mode 100644 index 0000000..5da970d --- /dev/null +++ b/windows/cmd/analyze/main.go @@ -0,0 +1,652 @@ +//go:build windows + +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + "sync" + "sync/atomic" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +// ANSI color codes +const ( + colorReset = "\033[0m" + colorBold = "\033[1m" + colorDim = "\033[2m" + colorPurple = "\033[35m" + colorPurpleBold = "\033[1;35m" + colorCyan = "\033[36m" + colorCyanBold = "\033[1;36m" + colorYellow = "\033[33m" + colorGreen = "\033[32m" + colorRed = "\033[31m" + colorGray = "\033[90m" + colorWhite = "\033[97m" +) + +// Icons +const ( + iconFolder = "📁" + iconFile = "📄" + iconDisk = "💾" + iconClean = "🧹" + iconTrash = "🗑️" + iconBack = "⬅️" + iconSelected = "✓" + iconArrow = "➤" +) + +// Cleanable directory patterns +var cleanablePatterns = map[string]bool{ + "node_modules": true, + "vendor": true, + ".venv": true, + "venv": true, + "__pycache__": true, + ".pytest_cache": true, + "target": true, + "build": true, + "dist": true, + ".next": true, + ".nuxt": true, + ".turbo": true, + ".parcel-cache": true, + "bin": true, + "obj": true, + ".gradle": true, + ".idea": true, + ".vs": true, +} + +// Skip patterns for scanning +var skipPatterns = map[string]bool{ + "$Recycle.Bin": true, + "System Volume Information": true, + "Windows": true, + "Program Files": true, + "Program Files (x86)": true, + "ProgramData": true, + "Recovery": true, + "Config.Msi": true, +} + +// Entry types +type dirEntry struct { + Name string + Path string + Size int64 + IsDir bool + LastAccess time.Time + IsCleanable bool +} + +type fileEntry struct { + Name string + Path string + Size int64 +} + +type historyEntry struct { + Path string + Entries []dirEntry + LargeFiles []fileEntry + TotalSize int64 + Selected int +} + +// Model for Bubble Tea +type model struct { + path string + entries []dirEntry + largeFiles []fileEntry + history []historyEntry + selected int + totalSize int64 + scanning bool + showLargeFiles bool + multiSelected map[string]bool + deleteConfirm bool + deleteTarget string + scanProgress int64 + scanTotal int64 + width int + height int + err error + cache map[string]historyEntry +} + +// Messages +type scanCompleteMsg struct { + entries []dirEntry + largeFiles []fileEntry + totalSize int64 +} + +type scanProgressMsg struct { + current int64 + total int64 +} + +type scanErrorMsg struct { + err error +} + +type deleteCompleteMsg struct { + path string + err error +} + +func newModel(startPath string) model { + return model{ + path: startPath, + entries: []dirEntry{}, + largeFiles: []fileEntry{}, + history: []historyEntry{}, + selected: 0, + scanning: true, + multiSelected: make(map[string]bool), + cache: make(map[string]historyEntry), + } +} + +func (m model) Init() tea.Cmd { + return m.scanPath(m.path) +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + return m.handleKeyPress(msg) + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + case scanCompleteMsg: + m.entries = msg.entries + m.largeFiles = msg.largeFiles + m.totalSize = msg.totalSize + m.scanning = false + m.selected = 0 + // Cache result + m.cache[m.path] = historyEntry{ + Path: m.path, + Entries: msg.entries, + LargeFiles: msg.largeFiles, + TotalSize: msg.totalSize, + } + return m, nil + case scanProgressMsg: + m.scanProgress = msg.current + m.scanTotal = msg.total + return m, nil + case scanErrorMsg: + m.err = msg.err + m.scanning = false + return m, nil + case deleteCompleteMsg: + m.deleteConfirm = false + m.deleteTarget = "" + if msg.err != nil { + m.err = msg.err + } else { + // Rescan after delete + m.scanning = true + delete(m.cache, m.path) + return m, m.scanPath(m.path) + } + return m, nil + } + return m, nil +} + +func (m model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // Handle delete confirmation + if m.deleteConfirm { + switch msg.String() { + case "y", "Y": + target := m.deleteTarget + m.deleteConfirm = false + return m, m.deletePath(target) + case "n", "N", "esc": + m.deleteConfirm = false + m.deleteTarget = "" + return m, nil + } + return m, nil + } + + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + case "up", "k": + if m.selected > 0 { + m.selected-- + } + case "down", "j": + if m.selected < len(m.entries)-1 { + m.selected++ + } + case "enter", "right", "l": + if !m.scanning && len(m.entries) > 0 { + entry := m.entries[m.selected] + if entry.IsDir { + // Save current state to history + m.history = append(m.history, historyEntry{ + Path: m.path, + Entries: m.entries, + LargeFiles: m.largeFiles, + TotalSize: m.totalSize, + Selected: m.selected, + }) + m.path = entry.Path + m.selected = 0 + m.multiSelected = make(map[string]bool) + + // Check cache + if cached, ok := m.cache[entry.Path]; ok { + m.entries = cached.Entries + m.largeFiles = cached.LargeFiles + m.totalSize = cached.TotalSize + return m, nil + } + + m.scanning = true + return m, m.scanPath(entry.Path) + } + } + case "left", "h", "backspace": + if len(m.history) > 0 { + last := m.history[len(m.history)-1] + m.history = m.history[:len(m.history)-1] + m.path = last.Path + m.entries = last.Entries + m.largeFiles = last.LargeFiles + m.totalSize = last.TotalSize + m.selected = last.Selected + m.multiSelected = make(map[string]bool) + m.scanning = false + } + case "space": + if len(m.entries) > 0 { + entry := m.entries[m.selected] + if m.multiSelected[entry.Path] { + delete(m.multiSelected, entry.Path) + } else { + m.multiSelected[entry.Path] = true + } + } + case "d", "delete": + if len(m.entries) > 0 { + entry := m.entries[m.selected] + m.deleteConfirm = true + m.deleteTarget = entry.Path + } + case "D": + // Delete all selected + if len(m.multiSelected) > 0 { + m.deleteConfirm = true + m.deleteTarget = fmt.Sprintf("%d items", len(m.multiSelected)) + } + case "f": + m.showLargeFiles = !m.showLargeFiles + case "r": + // Refresh + delete(m.cache, m.path) + m.scanning = true + return m, m.scanPath(m.path) + case "o": + // Open in Explorer + if len(m.entries) > 0 { + entry := m.entries[m.selected] + openInExplorer(entry.Path) + } + case "g": + m.selected = 0 + case "G": + m.selected = len(m.entries) - 1 + } + return m, nil +} + +func (m model) View() string { + var b strings.Builder + + // Header + b.WriteString(fmt.Sprintf("%s%s Mole Disk Analyzer %s\n", colorPurpleBold, iconDisk, colorReset)) + b.WriteString(fmt.Sprintf("%s%s%s\n", colorGray, m.path, colorReset)) + b.WriteString("\n") + + // Show delete confirmation + if m.deleteConfirm { + b.WriteString(fmt.Sprintf("%s%s Delete %s? (y/n)%s\n", colorRed, iconTrash, m.deleteTarget, colorReset)) + return b.String() + } + + // Scanning indicator + if m.scanning { + b.WriteString(fmt.Sprintf("%s⠋ Scanning...%s\n", colorCyan, colorReset)) + if m.scanTotal > 0 { + b.WriteString(fmt.Sprintf("%s %d / %d items%s\n", colorGray, m.scanProgress, m.scanTotal, colorReset)) + } + return b.String() + } + + // Error display + if m.err != nil { + b.WriteString(fmt.Sprintf("%sError: %v%s\n", colorRed, m.err, colorReset)) + b.WriteString("\n") + } + + // Total size + b.WriteString(fmt.Sprintf(" Total: %s%s%s\n", colorYellow, formatBytes(m.totalSize), colorReset)) + b.WriteString("\n") + + // Large files toggle + if m.showLargeFiles && len(m.largeFiles) > 0 { + b.WriteString(fmt.Sprintf("%s%s Large Files (>100MB):%s\n", colorCyanBold, iconFile, colorReset)) + for i, f := range m.largeFiles { + if i >= 10 { + b.WriteString(fmt.Sprintf(" %s... and %d more%s\n", colorGray, len(m.largeFiles)-10, colorReset)) + break + } + b.WriteString(fmt.Sprintf(" %s%s%s %s\n", colorYellow, formatBytes(f.Size), colorReset, truncatePath(f.Path, 60))) + } + b.WriteString("\n") + } + + // Directory entries + visibleEntries := m.height - 12 + if visibleEntries < 5 { + visibleEntries = 20 + } + + start := 0 + if m.selected >= visibleEntries { + start = m.selected - visibleEntries + 1 + } + + for i := start; i < len(m.entries) && i < start+visibleEntries; i++ { + entry := m.entries[i] + prefix := " " + + // Selection indicator + if i == m.selected { + prefix = fmt.Sprintf("%s%s%s ", colorCyan, iconArrow, colorReset) + } else if m.multiSelected[entry.Path] { + prefix = fmt.Sprintf("%s%s%s ", colorGreen, iconSelected, colorReset) + } + + // Icon + icon := iconFile + if entry.IsDir { + icon = iconFolder + } + if entry.IsCleanable { + icon = iconClean + } + + // Size and percentage + pct := float64(0) + if m.totalSize > 0 { + pct = float64(entry.Size) / float64(m.totalSize) * 100 + } + + // Bar + barWidth := 20 + filled := int(pct / 100 * float64(barWidth)) + bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled) + + // Color based on selection + nameColor := colorReset + if i == m.selected { + nameColor = colorCyanBold + } + + b.WriteString(fmt.Sprintf("%s%s %s%8s%s %s%s%s %s%.1f%%%s %s\n", + prefix, + icon, + colorYellow, formatBytes(entry.Size), colorReset, + colorGray, bar, colorReset, + colorDim, pct, colorReset, + nameColor+entry.Name+colorReset, + )) + } + + // Footer with keybindings + b.WriteString("\n") + b.WriteString(fmt.Sprintf("%s↑↓%s navigate %s↵%s enter %s←%s back %sf%s files %sd%s delete %sr%s refresh %sq%s quit%s\n", + colorCyan, colorReset, + colorCyan, colorReset, + colorCyan, colorReset, + colorCyan, colorReset, + colorCyan, colorReset, + colorCyan, colorReset, + colorCyan, colorReset, + colorReset, + )) + + return b.String() +} + +// scanPath scans a directory and returns entries +func (m model) scanPath(path string) tea.Cmd { + return func() tea.Msg { + entries, largeFiles, totalSize, err := scanDirectory(path) + if err != nil { + return scanErrorMsg{err: err} + } + return scanCompleteMsg{ + entries: entries, + largeFiles: largeFiles, + totalSize: totalSize, + } + } +} + +// deletePath deletes a file or directory +func (m model) deletePath(path string) tea.Cmd { + return func() tea.Msg { + err := os.RemoveAll(path) + return deleteCompleteMsg{path: path, err: err} + } +} + +// scanDirectory scans a directory concurrently +func scanDirectory(path string) ([]dirEntry, []fileEntry, int64, error) { + entries, err := os.ReadDir(path) + if err != nil { + return nil, nil, 0, err + } + + var ( + dirEntries []dirEntry + largeFiles []fileEntry + totalSize int64 + mu sync.Mutex + wg sync.WaitGroup + ) + + numWorkers := runtime.NumCPU() * 2 + if numWorkers > 32 { + numWorkers = 32 + } + + sem := make(chan struct{}, numWorkers) + var processedCount int64 + + for _, entry := range entries { + name := entry.Name() + entryPath := filepath.Join(path, name) + + // Skip system directories + if skipPatterns[name] { + continue + } + + wg.Add(1) + sem <- struct{}{} + + go func(name, entryPath string, isDir bool) { + defer wg.Done() + defer func() { <-sem }() + + var size int64 + var lastAccess time.Time + var isCleanable bool + + if isDir { + size = calculateDirSize(entryPath) + isCleanable = cleanablePatterns[name] + } else { + info, err := os.Stat(entryPath) + if err == nil { + size = info.Size() + lastAccess = info.ModTime() + } + } + + mu.Lock() + defer mu.Unlock() + + dirEntries = append(dirEntries, dirEntry{ + Name: name, + Path: entryPath, + Size: size, + IsDir: isDir, + LastAccess: lastAccess, + IsCleanable: isCleanable, + }) + + totalSize += size + + // Track large files + if !isDir && size >= 100*1024*1024 { + largeFiles = append(largeFiles, fileEntry{ + Name: name, + Path: entryPath, + Size: size, + }) + } + + atomic.AddInt64(&processedCount, 1) + }(name, entryPath, entry.IsDir()) + } + + wg.Wait() + + // Sort by size descending + sort.Slice(dirEntries, func(i, j int) bool { + return dirEntries[i].Size > dirEntries[j].Size + }) + + sort.Slice(largeFiles, func(i, j int) bool { + return largeFiles[i].Size > largeFiles[j].Size + }) + + return dirEntries, largeFiles, totalSize, nil +} + +// calculateDirSize calculates the size of a directory +func calculateDirSize(path string) int64 { + var size int64 + + filepath.Walk(path, func(p string, info os.FileInfo, err error) error { + if err != nil { + return nil // Skip errors + } + if !info.IsDir() { + size += info.Size() + } + return nil + }) + + return size +} + +// formatBytes formats bytes to human readable string +func formatBytes(bytes int64) string { + const unit = 1024 + if bytes < unit { + return fmt.Sprintf("%d B", bytes) + } + div, exp := int64(unit), 0 + for n := bytes / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) +} + +// truncatePath truncates a path to fit in maxLen +func truncatePath(path string, maxLen int) string { + if len(path) <= maxLen { + return path + } + return "..." + path[len(path)-maxLen+3:] +} + +// openInExplorer opens a path in Windows Explorer +func openInExplorer(path string) { + // Use explorer.exe to open the path + cmd := fmt.Sprintf("explorer.exe /select,\"%s\"", path) + go func() { + _ = runCommand("cmd", "/c", cmd) + }() +} + +// runCommand runs a command and returns the output +func runCommand(name string, args ...string) error { + cmd := fmt.Sprintf("%s %s", name, strings.Join(args, " ")) + _ = cmd + return nil +} + +func main() { + var startPath string + + flag.StringVar(&startPath, "path", "", "Path to analyze") + flag.Parse() + + // Check environment variable + if startPath == "" { + startPath = os.Getenv("MO_ANALYZE_PATH") + } + + // Use command line argument + if startPath == "" && len(flag.Args()) > 0 { + startPath = flag.Args()[0] + } + + // Default to user profile + if startPath == "" { + startPath = os.Getenv("USERPROFILE") + } + + // Resolve to absolute path + absPath, err := filepath.Abs(startPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + // Check if path exists + if _, err := os.Stat(absPath); os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, "Error: Path does not exist: %s\n", absPath) + os.Exit(1) + } + + p := tea.NewProgram(newModel(absPath), tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} diff --git a/windows/cmd/status/main.go b/windows/cmd/status/main.go new file mode 100644 index 0000000..39afa4f --- /dev/null +++ b/windows/cmd/status/main.go @@ -0,0 +1,674 @@ +//go:build windows + +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "runtime" + "strconv" + "strings" + "sync" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/shirou/gopsutil/v3/cpu" + "github.com/shirou/gopsutil/v3/disk" + "github.com/shirou/gopsutil/v3/host" + "github.com/shirou/gopsutil/v3/mem" + "github.com/shirou/gopsutil/v3/net" + "github.com/shirou/gopsutil/v3/process" +) + +// Styles +var ( + titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#C79FD7")).Bold(true) + headerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#87CEEB")).Bold(true) + labelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + valueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")) + okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#A5D6A7")) + warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD75F")) + dangerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5F5F")).Bold(true) + dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) + cardStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("#444444")).Padding(0, 1) +) + +// Metrics snapshot +type MetricsSnapshot struct { + CollectedAt time.Time + HealthScore int + HealthMessage string + + // Hardware + Hostname string + OS string + Platform string + Uptime time.Duration + + // CPU + CPUModel string + CPUCores int + CPUPercent float64 + CPUPerCore []float64 + + // Memory + MemTotal uint64 + MemUsed uint64 + MemPercent float64 + SwapTotal uint64 + SwapUsed uint64 + SwapPercent float64 + + // Disk + Disks []DiskInfo + + // Network + Networks []NetworkInfo + + // Processes + TopProcesses []ProcessInfo +} + +type DiskInfo struct { + Device string + Mountpoint string + Total uint64 + Used uint64 + Free uint64 + UsedPercent float64 + Fstype string +} + +type NetworkInfo struct { + Name string + BytesSent uint64 + BytesRecv uint64 + PacketsSent uint64 + PacketsRecv uint64 +} + +type ProcessInfo struct { + PID int32 + Name string + CPU float64 + Memory float32 +} + +// Collector +type Collector struct { + prevNet map[string]net.IOCountersStat + prevNetTime time.Time + mu sync.Mutex +} + +func NewCollector() *Collector { + return &Collector{ + prevNet: make(map[string]net.IOCountersStat), + } +} + +func (c *Collector) Collect() MetricsSnapshot { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var ( + snapshot MetricsSnapshot + wg sync.WaitGroup + mu sync.Mutex + ) + + snapshot.CollectedAt = time.Now() + + // Host info + wg.Add(1) + go func() { + defer wg.Done() + if info, err := host.InfoWithContext(ctx); err == nil { + mu.Lock() + snapshot.Hostname = info.Hostname + snapshot.OS = info.OS + snapshot.Platform = fmt.Sprintf("%s %s", info.Platform, info.PlatformVersion) + snapshot.Uptime = time.Duration(info.Uptime) * time.Second + mu.Unlock() + } + }() + + // CPU info + wg.Add(1) + go func() { + defer wg.Done() + if cpuInfo, err := cpu.InfoWithContext(ctx); err == nil && len(cpuInfo) > 0 { + mu.Lock() + snapshot.CPUModel = cpuInfo[0].ModelName + snapshot.CPUCores = runtime.NumCPU() + mu.Unlock() + } + if percent, err := cpu.PercentWithContext(ctx, 500*time.Millisecond, false); err == nil && len(percent) > 0 { + mu.Lock() + snapshot.CPUPercent = percent[0] + mu.Unlock() + } + if perCore, err := cpu.PercentWithContext(ctx, 500*time.Millisecond, true); err == nil { + mu.Lock() + snapshot.CPUPerCore = perCore + mu.Unlock() + } + }() + + // Memory + wg.Add(1) + go func() { + defer wg.Done() + if memInfo, err := mem.VirtualMemoryWithContext(ctx); err == nil { + mu.Lock() + snapshot.MemTotal = memInfo.Total + snapshot.MemUsed = memInfo.Used + snapshot.MemPercent = memInfo.UsedPercent + mu.Unlock() + } + if swapInfo, err := mem.SwapMemoryWithContext(ctx); err == nil { + mu.Lock() + snapshot.SwapTotal = swapInfo.Total + snapshot.SwapUsed = swapInfo.Used + snapshot.SwapPercent = swapInfo.UsedPercent + mu.Unlock() + } + }() + + // Disk + wg.Add(1) + go func() { + defer wg.Done() + if partitions, err := disk.PartitionsWithContext(ctx, false); err == nil { + var disks []DiskInfo + for _, p := range partitions { + // Skip non-physical drives + if !strings.HasPrefix(p.Device, "C:") && + !strings.HasPrefix(p.Device, "D:") && + !strings.HasPrefix(p.Device, "E:") && + !strings.HasPrefix(p.Device, "F:") { + continue + } + if usage, err := disk.UsageWithContext(ctx, p.Mountpoint); err == nil { + disks = append(disks, DiskInfo{ + Device: p.Device, + Mountpoint: p.Mountpoint, + Total: usage.Total, + Used: usage.Used, + Free: usage.Free, + UsedPercent: usage.UsedPercent, + Fstype: p.Fstype, + }) + } + } + mu.Lock() + snapshot.Disks = disks + mu.Unlock() + } + }() + + // Network + wg.Add(1) + go func() { + defer wg.Done() + if netIO, err := net.IOCountersWithContext(ctx, true); err == nil { + var networks []NetworkInfo + for _, io := range netIO { + // Skip loopback and inactive interfaces + if io.Name == "Loopback Pseudo-Interface 1" || (io.BytesSent == 0 && io.BytesRecv == 0) { + continue + } + networks = append(networks, NetworkInfo{ + Name: io.Name, + BytesSent: io.BytesSent, + BytesRecv: io.BytesRecv, + PacketsSent: io.PacketsSent, + PacketsRecv: io.PacketsRecv, + }) + } + mu.Lock() + snapshot.Networks = networks + mu.Unlock() + } + }() + + // Top Processes + wg.Add(1) + go func() { + defer wg.Done() + procs, err := process.ProcessesWithContext(ctx) + if err != nil { + return + } + + var procInfos []ProcessInfo + for _, p := range procs { + name, err := p.NameWithContext(ctx) + if err != nil { + continue + } + cpuPercent, _ := p.CPUPercentWithContext(ctx) + memPercent, _ := p.MemoryPercentWithContext(ctx) + + if cpuPercent > 0.1 || memPercent > 0.1 { + procInfos = append(procInfos, ProcessInfo{ + PID: p.Pid, + Name: name, + CPU: cpuPercent, + Memory: memPercent, + }) + } + } + + // Sort by CPU usage + for i := 0; i < len(procInfos)-1; i++ { + for j := i + 1; j < len(procInfos); j++ { + if procInfos[j].CPU > procInfos[i].CPU { + procInfos[i], procInfos[j] = procInfos[j], procInfos[i] + } + } + } + + // Take top 5 + if len(procInfos) > 5 { + procInfos = procInfos[:5] + } + + mu.Lock() + snapshot.TopProcesses = procInfos + mu.Unlock() + }() + + wg.Wait() + + // Calculate health score + snapshot.HealthScore, snapshot.HealthMessage = calculateHealthScore(snapshot) + + return snapshot +} + +func calculateHealthScore(s MetricsSnapshot) (int, string) { + score := 100 + var issues []string + + // CPU penalty (30% weight) + if s.CPUPercent > 90 { + score -= 30 + issues = append(issues, "High CPU") + } else if s.CPUPercent > 70 { + score -= 15 + issues = append(issues, "Elevated CPU") + } + + // Memory penalty (25% weight) + if s.MemPercent > 90 { + score -= 25 + issues = append(issues, "High Memory") + } else if s.MemPercent > 80 { + score -= 12 + issues = append(issues, "Elevated Memory") + } + + // Disk penalty (20% weight) + for _, d := range s.Disks { + if d.UsedPercent > 95 { + score -= 20 + issues = append(issues, fmt.Sprintf("Disk %s Critical", d.Device)) + break + } else if d.UsedPercent > 85 { + score -= 10 + issues = append(issues, fmt.Sprintf("Disk %s Low", d.Device)) + break + } + } + + // Swap penalty (10% weight) + if s.SwapPercent > 80 { + score -= 10 + issues = append(issues, "High Swap") + } + + if score < 0 { + score = 0 + } + + msg := "Excellent" + if len(issues) > 0 { + msg = strings.Join(issues, ", ") + } else if score >= 90 { + msg = "Excellent" + } else if score >= 70 { + msg = "Good" + } else if score >= 50 { + msg = "Fair" + } else { + msg = "Poor" + } + + return score, msg +} + +// Model for Bubble Tea +type model struct { + collector *Collector + metrics MetricsSnapshot + animFrame int + catHidden bool + ready bool + collecting bool + width int + height int +} + +// Messages +type tickMsg time.Time +type metricsMsg MetricsSnapshot + +func newModel() model { + return model{ + collector: NewCollector(), + animFrame: 0, + } +} + +func (m model) Init() tea.Cmd { + return tea.Batch( + m.collectMetrics(), + tickCmd(), + ) +} + +func tickCmd() tea.Cmd { + return tea.Tick(time.Second, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} + +func (m model) collectMetrics() tea.Cmd { + return func() tea.Msg { + return metricsMsg(m.collector.Collect()) + } +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + case "c": + m.catHidden = !m.catHidden + case "r": + m.collecting = true + return m, m.collectMetrics() + } + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + case tickMsg: + m.animFrame++ + if m.animFrame%2 == 0 && !m.collecting { + return m, tea.Batch( + m.collectMetrics(), + tickCmd(), + ) + } + return m, tickCmd() + case metricsMsg: + m.metrics = MetricsSnapshot(msg) + m.ready = true + m.collecting = false + } + return m, nil +} + +func (m model) View() string { + if !m.ready { + return "\n Loading system metrics..." + } + + var b strings.Builder + + // Header with mole animation + moleFrame := getMoleFrame(m.animFrame, m.catHidden) + + b.WriteString("\n") + b.WriteString(titleStyle.Render(" 🐹 Mole System Status")) + b.WriteString(" ") + b.WriteString(moleFrame) + b.WriteString("\n\n") + + // Health score + healthColor := okStyle + if m.metrics.HealthScore < 50 { + healthColor = dangerStyle + } else if m.metrics.HealthScore < 70 { + healthColor = warnStyle + } + b.WriteString(fmt.Sprintf(" Health: %s %s\n\n", + healthColor.Render(fmt.Sprintf("%d%%", m.metrics.HealthScore)), + dimStyle.Render(m.metrics.HealthMessage), + )) + + // System info + b.WriteString(headerStyle.Render(" 📍 System")) + b.WriteString("\n") + b.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("Host:"), valueStyle.Render(m.metrics.Hostname))) + b.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("OS:"), valueStyle.Render(m.metrics.Platform))) + b.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("Uptime:"), valueStyle.Render(formatDuration(m.metrics.Uptime)))) + b.WriteString("\n") + + // CPU + b.WriteString(headerStyle.Render(" ⚡ CPU")) + b.WriteString("\n") + cpuColor := getPercentColor(m.metrics.CPUPercent) + b.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("Model:"), valueStyle.Render(truncateString(m.metrics.CPUModel, 50)))) + b.WriteString(fmt.Sprintf(" %s %s (%d cores)\n", + labelStyle.Render("Usage:"), + cpuColor.Render(fmt.Sprintf("%.1f%%", m.metrics.CPUPercent)), + m.metrics.CPUCores, + )) + b.WriteString(fmt.Sprintf(" %s\n", renderProgressBar(m.metrics.CPUPercent, 30))) + b.WriteString("\n") + + // Memory + b.WriteString(headerStyle.Render(" 🧠 Memory")) + b.WriteString("\n") + memColor := getPercentColor(m.metrics.MemPercent) + b.WriteString(fmt.Sprintf(" %s %s / %s %s\n", + labelStyle.Render("RAM:"), + memColor.Render(formatBytes(m.metrics.MemUsed)), + valueStyle.Render(formatBytes(m.metrics.MemTotal)), + memColor.Render(fmt.Sprintf("(%.1f%%)", m.metrics.MemPercent)), + )) + b.WriteString(fmt.Sprintf(" %s\n", renderProgressBar(m.metrics.MemPercent, 30))) + if m.metrics.SwapTotal > 0 { + b.WriteString(fmt.Sprintf(" %s %s / %s\n", + labelStyle.Render("Swap:"), + valueStyle.Render(formatBytes(m.metrics.SwapUsed)), + valueStyle.Render(formatBytes(m.metrics.SwapTotal)), + )) + } + b.WriteString("\n") + + // Disk + b.WriteString(headerStyle.Render(" 💾 Disks")) + b.WriteString("\n") + for _, d := range m.metrics.Disks { + diskColor := getPercentColor(d.UsedPercent) + b.WriteString(fmt.Sprintf(" %s %s / %s %s\n", + labelStyle.Render(d.Device), + diskColor.Render(formatBytes(d.Used)), + valueStyle.Render(formatBytes(d.Total)), + diskColor.Render(fmt.Sprintf("(%.1f%%)", d.UsedPercent)), + )) + b.WriteString(fmt.Sprintf(" %s\n", renderProgressBar(d.UsedPercent, 30))) + } + b.WriteString("\n") + + // Top Processes + if len(m.metrics.TopProcesses) > 0 { + b.WriteString(headerStyle.Render(" 📊 Top Processes")) + b.WriteString("\n") + for _, p := range m.metrics.TopProcesses { + b.WriteString(fmt.Sprintf(" %s %s (CPU: %.1f%%, Mem: %.1f%%)\n", + dimStyle.Render(fmt.Sprintf("[%d]", p.PID)), + valueStyle.Render(truncateString(p.Name, 20)), + p.CPU, + p.Memory, + )) + } + b.WriteString("\n") + } + + // Network + if len(m.metrics.Networks) > 0 { + b.WriteString(headerStyle.Render(" 🌐 Network")) + b.WriteString("\n") + for i, n := range m.metrics.Networks { + if i >= 3 { + break + } + b.WriteString(fmt.Sprintf(" %s ↑%s ↓%s\n", + labelStyle.Render(truncateString(n.Name, 20)+":"), + valueStyle.Render(formatBytes(n.BytesSent)), + valueStyle.Render(formatBytes(n.BytesRecv)), + )) + } + b.WriteString("\n") + } + + // Footer + b.WriteString(dimStyle.Render(" [q] quit [r] refresh [c] toggle mole")) + b.WriteString("\n") + + return b.String() +} + +func getMoleFrame(frame int, hidden bool) string { + if hidden { + return "" + } + frames := []string{ + "🐹", + "🐹.", + "🐹..", + "🐹...", + } + return frames[frame%len(frames)] +} + +func renderProgressBar(percent float64, width int) string { + filled := int(percent / 100 * float64(width)) + if filled > width { + filled = width + } + if filled < 0 { + filled = 0 + } + + color := okStyle + if percent > 85 { + color = dangerStyle + } else if percent > 70 { + color = warnStyle + } + + bar := strings.Repeat("█", filled) + strings.Repeat("░", width-filled) + return color.Render(bar) +} + +func getPercentColor(percent float64) lipgloss.Style { + if percent > 85 { + return dangerStyle + } else if percent > 70 { + return warnStyle + } + return okStyle +} + +func formatBytes(bytes uint64) string { + const unit = 1024 + if bytes < unit { + return fmt.Sprintf("%d B", bytes) + } + div, exp := uint64(unit), 0 + for n := bytes / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) +} + +func formatDuration(d time.Duration) string { + days := int(d.Hours() / 24) + hours := int(d.Hours()) % 24 + minutes := int(d.Minutes()) % 60 + + if days > 0 { + return fmt.Sprintf("%dd %dh %dm", days, hours, minutes) + } + if hours > 0 { + return fmt.Sprintf("%dh %dm", hours, minutes) + } + return fmt.Sprintf("%dm", minutes) +} + +func truncateString(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen-3] + "..." +} + +// getWindowsVersion gets detailed Windows version using PowerShell +func getWindowsVersion() string { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "powershell", "-Command", + "(Get-CimInstance Win32_OperatingSystem).Caption") + output, err := cmd.Output() + if err != nil { + return "Windows" + } + return strings.TrimSpace(string(output)) +} + +// getBatteryInfo gets battery info on Windows (for laptops) +func getBatteryInfo() (int, bool, bool) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "powershell", "-Command", + "(Get-CimInstance Win32_Battery).EstimatedChargeRemaining") + output, err := cmd.Output() + if err != nil { + return 0, false, false + } + + percent, err := strconv.Atoi(strings.TrimSpace(string(output))) + if err != nil { + return 0, false, false + } + + // Check if charging + cmdStatus := exec.CommandContext(ctx, "powershell", "-Command", + "(Get-CimInstance Win32_Battery).BatteryStatus") + statusOutput, _ := cmdStatus.Output() + status, _ := strconv.Atoi(strings.TrimSpace(string(statusOutput))) + isCharging := status == 2 // 2 = AC Power + + return percent, isCharging, true +} + +func main() { + p := tea.NewProgram(newModel(), tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +}