mirror of
https://github.com/tw93/Mole.git
synced 2026-02-09 23:09:17 +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:
16
windows/.gitignore
vendored
Normal file
16
windows/.gitignore
vendored
Normal 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
44
windows/Makefile
Normal 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 ./...
|
||||||
@@ -6,7 +6,7 @@ Windows support for [Mole](https://github.com/tw93/Mole) - A system maintenance
|
|||||||
|
|
||||||
- Windows 10/11
|
- Windows 10/11
|
||||||
- PowerShell 5.1 or later (pre-installed on 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
|
## Installation
|
||||||
|
|
||||||
@@ -48,14 +48,35 @@ mole -ShowHelp
|
|||||||
|
|
||||||
# Show version
|
# Show version
|
||||||
mole -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
|
## Environment Variables
|
||||||
|
|
||||||
| Variable | Description |
|
| Variable | Description |
|
||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
| `MOLE_DRY_RUN=1` | Preview changes without making them |
|
| `MOLE_DRY_RUN=1` | Preview changes without making them |
|
||||||
| `MOLE_DEBUG=1` | Enable debug output |
|
| `MOLE_DEBUG=1` | Enable debug output |
|
||||||
|
| `MO_ANALYZE_PATH` | Starting path for analyze tool |
|
||||||
|
|
||||||
## Directory Structure
|
## Directory Structure
|
||||||
|
|
||||||
@@ -63,15 +84,51 @@ mole -Version
|
|||||||
windows/
|
windows/
|
||||||
├── mole.ps1 # Main CLI entry point
|
├── mole.ps1 # Main CLI entry point
|
||||||
├── install.ps1 # Windows installer
|
├── install.ps1 # Windows installer
|
||||||
|
├── Makefile # Build automation for Go tools
|
||||||
├── go.mod # Go module definition
|
├── go.mod # Go module definition
|
||||||
├── go.sum # Go dependencies
|
├── 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/
|
└── lib/
|
||||||
└── core/
|
├── core/
|
||||||
├── base.ps1 # Core definitions and utilities
|
│ ├── base.ps1 # Core definitions and utilities
|
||||||
├── common.ps1 # Common functions loader
|
│ ├── common.ps1 # Common functions loader
|
||||||
├── file_ops.ps1 # Safe file operations
|
│ ├── file_ops.ps1 # Safe file operations
|
||||||
├── log.ps1 # Logging functions
|
│ ├── log.ps1 # Logging functions
|
||||||
└── ui.ps1 # Interactive UI components
|
│ └── 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
|
## Configuration
|
||||||
@@ -80,26 +137,27 @@ Mole stores its configuration in:
|
|||||||
- Config: `~\.config\mole\`
|
- Config: `~\.config\mole\`
|
||||||
- Cache: `~\.cache\mole\`
|
- Cache: `~\.cache\mole\`
|
||||||
- Whitelist: `~\.config\mole\whitelist.txt`
|
- 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] `install.ps1` - Windows installer
|
||||||
- [x] `mole.ps1` - Main CLI entry point
|
- [x] `mole.ps1` - Main CLI entry point
|
||||||
- [x] `lib/core/*` - Core utility libraries
|
- [x] `lib/core/*` - Core utility libraries
|
||||||
|
|
||||||
### Phase 2: Cleanup Features (Planned)
|
### Phase 2: Cleanup Features ✅
|
||||||
- [ ] `bin/clean.ps1` - Deep cleanup orchestrator
|
- [x] `bin/clean.ps1` - Deep cleanup orchestrator
|
||||||
- [ ] `bin/uninstall.ps1` - App removal with leftover detection
|
- [x] `bin/uninstall.ps1` - App removal with leftover detection
|
||||||
- [ ] `bin/optimize.ps1` - Cache rebuild and service refresh
|
- [x] `bin/optimize.ps1` - System optimization
|
||||||
- [ ] `bin/purge.ps1` - Aggressive cleanup mode
|
- [x] `bin/purge.ps1` - Project artifact cleanup
|
||||||
- [ ] `lib/clean/*` - Cleanup modules
|
- [x] `lib/clean/*` - Cleanup modules
|
||||||
|
|
||||||
### Phase 3: TUI Tools (Planned)
|
### Phase 3: TUI Tools ✅
|
||||||
- [ ] `cmd/analyze/` - Disk usage analyzer (Go)
|
- [x] `cmd/analyze/` - Disk usage analyzer (Go)
|
||||||
- [ ] `cmd/status/` - Real-time system monitor (Go)
|
- [x] `cmd/status/` - Real-time system monitor (Go)
|
||||||
- [ ] `bin/analyze.ps1` - Analyzer wrapper
|
- [x] `bin/analyze.ps1` - Analyzer wrapper
|
||||||
- [ ] `bin/status.ps1` - Status wrapper
|
- [x] `bin/status.ps1` - Status wrapper
|
||||||
|
|
||||||
### Phase 4: Testing & CI (Planned)
|
### Phase 4: Testing & CI (Planned)
|
||||||
- [ ] `tests/` - Pester tests
|
- [ ] `tests/` - Pester tests
|
||||||
|
|||||||
79
windows/bin/analyze.ps1
Normal file
79
windows/bin/analyze.ps1
Normal 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
73
windows/bin/status.ps1
Normal 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
652
windows/cmd/analyze/main.go
Normal 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
674
windows/cmd/status/main.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user