1
0
mirror of https://github.com/tw93/Mole.git synced 2026-02-04 16:49:41 +00:00

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
This commit is contained in:
Bhadra
2026-01-08 16:18:26 +05:30
parent 6e0d850d6a
commit 8a37f63bc7
7 changed files with 1616 additions and 20 deletions

16
windows/.gitignore vendored Normal file
View File

@@ -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

44
windows/Makefile Normal file
View File

@@ -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 ./...

View File

@@ -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

79
windows/bin/analyze.ps1 Normal file
View File

@@ -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

73
windows/bin/status.ps1 Normal file
View File

@@ -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

652
windows/cmd/analyze/main.go Normal file
View File

@@ -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)
}
}

674
windows/cmd/status/main.go Normal file
View File

@@ -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)
}
}