1
0
mirror of https://github.com/tw93/Mole.git synced 2026-02-06 10:47:58 +00:00

chore: restructure windows branch (move windows/ content to root, remove macos files)

This commit is contained in:
Tw93
2026-01-10 13:23:29 +08:00
parent e84a457c2f
commit edf5ed09a9
140 changed files with 1472 additions and 34059 deletions

BIN
cmd/analyze/analyze.exe Normal file

Binary file not shown.

View File

@@ -1,360 +0,0 @@
package main
import (
"encoding/gob"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
)
func resetOverviewSnapshotForTest() {
overviewSnapshotMu.Lock()
overviewSnapshotCache = nil
overviewSnapshotLoaded = false
overviewSnapshotMu.Unlock()
}
func TestScanPathConcurrentBasic(t *testing.T) {
root := t.TempDir()
rootFile := filepath.Join(root, "root.txt")
if err := os.WriteFile(rootFile, []byte("root-data"), 0o644); err != nil {
t.Fatalf("write root file: %v", err)
}
nested := filepath.Join(root, "nested")
if err := os.MkdirAll(nested, 0o755); err != nil {
t.Fatalf("create nested dir: %v", err)
}
fileOne := filepath.Join(nested, "a.bin")
if err := os.WriteFile(fileOne, []byte("alpha"), 0o644); err != nil {
t.Fatalf("write file one: %v", err)
}
fileTwo := filepath.Join(nested, "b.bin")
if err := os.WriteFile(fileTwo, []byte(strings.Repeat("b", 32)), 0o644); err != nil {
t.Fatalf("write file two: %v", err)
}
linkPath := filepath.Join(root, "link-to-a")
if err := os.Symlink(fileOne, linkPath); err != nil {
t.Fatalf("create symlink: %v", err)
}
var filesScanned, dirsScanned, bytesScanned int64
current := ""
result, err := scanPathConcurrent(root, &filesScanned, &dirsScanned, &bytesScanned, &current)
if err != nil {
t.Fatalf("scanPathConcurrent returned error: %v", err)
}
linkInfo, err := os.Lstat(linkPath)
if err != nil {
t.Fatalf("stat symlink: %v", err)
}
expectedDirSize := int64(len("alpha") + len(strings.Repeat("b", 32)))
expectedRootFileSize := int64(len("root-data"))
expectedLinkSize := getActualFileSize(linkPath, linkInfo)
expectedTotal := expectedDirSize + expectedRootFileSize + expectedLinkSize
if result.TotalSize != expectedTotal {
t.Fatalf("expected total size %d, got %d", expectedTotal, result.TotalSize)
}
if got := atomic.LoadInt64(&filesScanned); got != 3 {
t.Fatalf("expected 3 files scanned, got %d", got)
}
if dirs := atomic.LoadInt64(&dirsScanned); dirs == 0 {
t.Fatalf("expected directory scan count to increase")
}
if bytes := atomic.LoadInt64(&bytesScanned); bytes == 0 {
t.Fatalf("expected byte counter to increase")
}
foundSymlink := false
for _, entry := range result.Entries {
if strings.HasSuffix(entry.Name, " →") {
foundSymlink = true
if entry.IsDir {
t.Fatalf("symlink entry should not be marked as directory")
}
}
}
if !foundSymlink {
t.Fatalf("expected symlink entry to be present in scan result")
}
}
func TestDeletePathWithProgress(t *testing.T) {
// Skip in CI environments where Finder may not be available.
if os.Getenv("CI") != "" {
t.Skip("Skipping Finder-dependent test in CI")
}
parent := t.TempDir()
target := filepath.Join(parent, "target")
if err := os.MkdirAll(target, 0o755); err != nil {
t.Fatalf("create target: %v", err)
}
files := []string{
filepath.Join(target, "one.txt"),
filepath.Join(target, "two.txt"),
}
for _, f := range files {
if err := os.WriteFile(f, []byte("content"), 0o644); err != nil {
t.Fatalf("write %s: %v", f, err)
}
}
var counter int64
count, err := trashPathWithProgress(target, &counter)
if err != nil {
t.Fatalf("trashPathWithProgress returned error: %v", err)
}
if count != int64(len(files)) {
t.Fatalf("expected %d files trashed, got %d", len(files), count)
}
if _, err := os.Stat(target); !os.IsNotExist(err) {
t.Fatalf("expected target to be moved to Trash, stat err=%v", err)
}
}
func TestOverviewStoreAndLoad(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
resetOverviewSnapshotForTest()
t.Cleanup(resetOverviewSnapshotForTest)
path := filepath.Join(home, "project")
want := int64(123456)
if err := storeOverviewSize(path, want); err != nil {
t.Fatalf("storeOverviewSize: %v", err)
}
got, err := loadStoredOverviewSize(path)
if err != nil {
t.Fatalf("loadStoredOverviewSize: %v", err)
}
if got != want {
t.Fatalf("snapshot mismatch: want %d, got %d", want, got)
}
// Reload from disk and ensure value persists.
resetOverviewSnapshotForTest()
got, err = loadStoredOverviewSize(path)
if err != nil {
t.Fatalf("loadStoredOverviewSize after reset: %v", err)
}
if got != want {
t.Fatalf("snapshot mismatch after reset: want %d, got %d", want, got)
}
}
func TestCacheSaveLoadRoundTrip(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
target := filepath.Join(home, "cache-target")
if err := os.MkdirAll(target, 0o755); err != nil {
t.Fatalf("create target dir: %v", err)
}
result := scanResult{
Entries: []dirEntry{
{Name: "alpha", Path: filepath.Join(target, "alpha"), Size: 10, IsDir: true},
},
LargeFiles: []fileEntry{
{Name: "big.bin", Path: filepath.Join(target, "big.bin"), Size: 2048},
},
TotalSize: 42,
}
if err := saveCacheToDisk(target, result); err != nil {
t.Fatalf("saveCacheToDisk: %v", err)
}
cache, err := loadCacheFromDisk(target)
if err != nil {
t.Fatalf("loadCacheFromDisk: %v", err)
}
if cache.TotalSize != result.TotalSize {
t.Fatalf("total size mismatch: want %d, got %d", result.TotalSize, cache.TotalSize)
}
if len(cache.Entries) != len(result.Entries) {
t.Fatalf("entry count mismatch: want %d, got %d", len(result.Entries), len(cache.Entries))
}
if len(cache.LargeFiles) != len(result.LargeFiles) {
t.Fatalf("large file count mismatch: want %d, got %d", len(result.LargeFiles), len(cache.LargeFiles))
}
}
func TestMeasureOverviewSize(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
resetOverviewSnapshotForTest()
t.Cleanup(resetOverviewSnapshotForTest)
target := filepath.Join(home, "measure")
if err := os.MkdirAll(target, 0o755); err != nil {
t.Fatalf("create target: %v", err)
}
content := []byte(strings.Repeat("x", 2048))
if err := os.WriteFile(filepath.Join(target, "data.bin"), content, 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
size, err := measureOverviewSize(target)
if err != nil {
t.Fatalf("measureOverviewSize: %v", err)
}
if size <= 0 {
t.Fatalf("expected positive size, got %d", size)
}
// Ensure snapshot stored.
cached, err := loadStoredOverviewSize(target)
if err != nil {
t.Fatalf("loadStoredOverviewSize: %v", err)
}
if cached != size {
t.Fatalf("snapshot mismatch: want %d, got %d", size, cached)
}
}
func TestIsCleanableDir(t *testing.T) {
if !isCleanableDir("/Users/test/project/node_modules") {
t.Fatalf("expected node_modules to be cleanable")
}
if isCleanableDir("/Users/test/Library/Caches/AppCache") {
t.Fatalf("Library caches should be handled by mo clean")
}
if isCleanableDir("") {
t.Fatalf("empty path should not be cleanable")
}
}
func TestHasUsefulVolumeMounts(t *testing.T) {
root := t.TempDir()
if hasUsefulVolumeMounts(root) {
t.Fatalf("empty directory should not report useful mounts")
}
hidden := filepath.Join(root, ".hidden")
if err := os.Mkdir(hidden, 0o755); err != nil {
t.Fatalf("create hidden dir: %v", err)
}
if hasUsefulVolumeMounts(root) {
t.Fatalf("hidden entries should not count as useful mounts")
}
mount := filepath.Join(root, "ExternalDrive")
if err := os.Mkdir(mount, 0o755); err != nil {
t.Fatalf("create mount dir: %v", err)
}
if !hasUsefulVolumeMounts(root) {
t.Fatalf("expected useful mount when real directory exists")
}
}
func TestLoadCacheExpiresWhenDirectoryChanges(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
target := filepath.Join(home, "change-target")
if err := os.MkdirAll(target, 0o755); err != nil {
t.Fatalf("create target: %v", err)
}
result := scanResult{TotalSize: 5}
if err := saveCacheToDisk(target, result); err != nil {
t.Fatalf("saveCacheToDisk: %v", err)
}
// Advance mtime beyond grace period.
time.Sleep(time.Millisecond * 10)
if err := os.Chtimes(target, time.Now(), time.Now()); err != nil {
t.Fatalf("chtimes: %v", err)
}
// Simulate older cache entry to exceed grace window.
cachePath, err := getCachePath(target)
if err != nil {
t.Fatalf("getCachePath: %v", err)
}
if _, err := os.Stat(cachePath); err != nil {
t.Fatalf("stat cache: %v", err)
}
oldTime := time.Now().Add(-cacheModTimeGrace - time.Minute)
if err := os.Chtimes(cachePath, oldTime, oldTime); err != nil {
t.Fatalf("chtimes cache: %v", err)
}
file, err := os.Open(cachePath)
if err != nil {
t.Fatalf("open cache: %v", err)
}
var entry cacheEntry
if err := gob.NewDecoder(file).Decode(&entry); err != nil {
t.Fatalf("decode cache: %v", err)
}
_ = file.Close()
entry.ScanTime = time.Now().Add(-8 * 24 * time.Hour)
tmp := cachePath + ".tmp"
f, err := os.Create(tmp)
if err != nil {
t.Fatalf("create tmp cache: %v", err)
}
if err := gob.NewEncoder(f).Encode(&entry); err != nil {
t.Fatalf("encode tmp cache: %v", err)
}
_ = f.Close()
if err := os.Rename(tmp, cachePath); err != nil {
t.Fatalf("rename tmp cache: %v", err)
}
if _, err := loadCacheFromDisk(target); err == nil {
t.Fatalf("expected cache load to fail after stale scan time")
}
}
func TestScanPathPermissionError(t *testing.T) {
root := t.TempDir()
lockedDir := filepath.Join(root, "locked")
if err := os.Mkdir(lockedDir, 0o755); err != nil {
t.Fatalf("create locked dir: %v", err)
}
// Create a file before locking.
if err := os.WriteFile(filepath.Join(lockedDir, "secret.txt"), []byte("shh"), 0o644); err != nil {
t.Fatalf("write secret: %v", err)
}
// Remove permissions.
if err := os.Chmod(lockedDir, 0o000); err != nil {
t.Fatalf("chmod 000: %v", err)
}
defer func() {
// Restore permissions for cleanup.
_ = os.Chmod(lockedDir, 0o755)
}()
var files, dirs, bytes int64
current := ""
// Scanning the locked dir itself should fail.
_, err := scanPathConcurrent(lockedDir, &files, &dirs, &bytes, &current)
if err == nil {
t.Fatalf("expected error scanning locked directory, got nil")
}
if !os.IsPermission(err) {
t.Logf("unexpected error type: %v", err)
}
}

View File

@@ -1,346 +0,0 @@
package main
import (
"context"
"encoding/gob"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/cespare/xxhash/v2"
)
type overviewSizeSnapshot struct {
Size int64 `json:"size"`
Updated time.Time `json:"updated"`
}
var (
overviewSnapshotMu sync.Mutex
overviewSnapshotCache map[string]overviewSizeSnapshot
overviewSnapshotLoaded bool
)
func snapshotFromModel(m model) historyEntry {
return historyEntry{
Path: m.path,
Entries: cloneDirEntries(m.entries),
LargeFiles: cloneFileEntries(m.largeFiles),
TotalSize: m.totalSize,
TotalFiles: m.totalFiles,
Selected: m.selected,
EntryOffset: m.offset,
LargeSelected: m.largeSelected,
LargeOffset: m.largeOffset,
IsOverview: m.isOverview,
}
}
func cacheSnapshot(m model) historyEntry {
entry := snapshotFromModel(m)
entry.Dirty = false
return entry
}
func cloneDirEntries(entries []dirEntry) []dirEntry {
if len(entries) == 0 {
return nil
}
copied := make([]dirEntry, len(entries))
copy(copied, entries) //nolint:all
return copied
}
func cloneFileEntries(files []fileEntry) []fileEntry {
if len(files) == 0 {
return nil
}
copied := make([]fileEntry, len(files))
copy(copied, files) //nolint:all
return copied
}
func ensureOverviewSnapshotCacheLocked() error {
if overviewSnapshotLoaded {
return nil
}
storePath, err := getOverviewSizeStorePath()
if err != nil {
return err
}
data, err := os.ReadFile(storePath)
if err != nil {
if os.IsNotExist(err) {
overviewSnapshotCache = make(map[string]overviewSizeSnapshot)
overviewSnapshotLoaded = true
return nil
}
return err
}
if len(data) == 0 {
overviewSnapshotCache = make(map[string]overviewSizeSnapshot)
overviewSnapshotLoaded = true
return nil
}
var snapshots map[string]overviewSizeSnapshot
if err := json.Unmarshal(data, &snapshots); err != nil || snapshots == nil {
backupPath := storePath + ".corrupt"
_ = os.Rename(storePath, backupPath)
overviewSnapshotCache = make(map[string]overviewSizeSnapshot)
overviewSnapshotLoaded = true
return nil
}
overviewSnapshotCache = snapshots
overviewSnapshotLoaded = true
return nil
}
func getOverviewSizeStorePath() (string, error) {
cacheDir, err := getCacheDir()
if err != nil {
return "", err
}
return filepath.Join(cacheDir, overviewCacheFile), nil
}
func loadStoredOverviewSize(path string) (int64, error) {
if path == "" {
return 0, fmt.Errorf("empty path")
}
overviewSnapshotMu.Lock()
defer overviewSnapshotMu.Unlock()
if err := ensureOverviewSnapshotCacheLocked(); err != nil {
return 0, err
}
if overviewSnapshotCache == nil {
return 0, fmt.Errorf("snapshot cache unavailable")
}
if snapshot, ok := overviewSnapshotCache[path]; ok && snapshot.Size > 0 {
if time.Since(snapshot.Updated) < overviewCacheTTL {
return snapshot.Size, nil
}
return 0, fmt.Errorf("snapshot expired")
}
return 0, fmt.Errorf("snapshot not found")
}
func storeOverviewSize(path string, size int64) error {
if path == "" || size <= 0 {
return fmt.Errorf("invalid overview size")
}
overviewSnapshotMu.Lock()
defer overviewSnapshotMu.Unlock()
if err := ensureOverviewSnapshotCacheLocked(); err != nil {
return err
}
if overviewSnapshotCache == nil {
overviewSnapshotCache = make(map[string]overviewSizeSnapshot)
}
overviewSnapshotCache[path] = overviewSizeSnapshot{
Size: size,
Updated: time.Now(),
}
return persistOverviewSnapshotLocked()
}
func persistOverviewSnapshotLocked() error {
storePath, err := getOverviewSizeStorePath()
if err != nil {
return err
}
tmpPath := storePath + ".tmp"
data, err := json.MarshalIndent(overviewSnapshotCache, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(tmpPath, data, 0644); err != nil {
return err
}
return os.Rename(tmpPath, storePath)
}
func loadOverviewCachedSize(path string) (int64, error) {
if path == "" {
return 0, fmt.Errorf("empty path")
}
if snapshot, err := loadStoredOverviewSize(path); err == nil {
return snapshot, nil
}
cacheEntry, err := loadCacheFromDisk(path)
if err != nil {
return 0, err
}
_ = storeOverviewSize(path, cacheEntry.TotalSize)
return cacheEntry.TotalSize, nil
}
func getCacheDir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
cacheDir := filepath.Join(home, ".cache", "mole")
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return "", err
}
return cacheDir, nil
}
func getCachePath(path string) (string, error) {
cacheDir, err := getCacheDir()
if err != nil {
return "", err
}
hash := xxhash.Sum64String(path)
filename := fmt.Sprintf("%x.cache", hash)
return filepath.Join(cacheDir, filename), nil
}
func loadCacheFromDisk(path string) (*cacheEntry, error) {
cachePath, err := getCachePath(path)
if err != nil {
return nil, err
}
file, err := os.Open(cachePath)
if err != nil {
return nil, err
}
defer file.Close() //nolint:errcheck
var entry cacheEntry
decoder := gob.NewDecoder(file)
if err := decoder.Decode(&entry); err != nil {
return nil, err
}
info, err := os.Stat(path)
if err != nil {
return nil, err
}
if info.ModTime().After(entry.ModTime) {
// Allow grace window.
if cacheModTimeGrace <= 0 || info.ModTime().Sub(entry.ModTime) > cacheModTimeGrace {
return nil, fmt.Errorf("cache expired: directory modified")
}
}
if time.Since(entry.ScanTime) > 7*24*time.Hour {
return nil, fmt.Errorf("cache expired: too old")
}
return &entry, nil
}
func saveCacheToDisk(path string, result scanResult) error {
cachePath, err := getCachePath(path)
if err != nil {
return err
}
info, err := os.Stat(path)
if err != nil {
return err
}
entry := cacheEntry{
Entries: result.Entries,
LargeFiles: result.LargeFiles,
TotalSize: result.TotalSize,
TotalFiles: result.TotalFiles,
ModTime: info.ModTime(),
ScanTime: time.Now(),
}
file, err := os.Create(cachePath)
if err != nil {
return err
}
defer file.Close() //nolint:errcheck
encoder := gob.NewEncoder(file)
return encoder.Encode(entry)
}
// peekCacheTotalFiles attempts to read the total file count from cache,
// ignoring expiration. Used for initial scan progress estimates.
func peekCacheTotalFiles(path string) (int64, error) {
cachePath, err := getCachePath(path)
if err != nil {
return 0, err
}
file, err := os.Open(cachePath)
if err != nil {
return 0, err
}
defer file.Close() //nolint:errcheck
var entry cacheEntry
decoder := gob.NewDecoder(file)
if err := decoder.Decode(&entry); err != nil {
return 0, err
}
return entry.TotalFiles, nil
}
func invalidateCache(path string) {
cachePath, err := getCachePath(path)
if err == nil {
_ = os.Remove(cachePath)
}
removeOverviewSnapshot(path)
}
func removeOverviewSnapshot(path string) {
if path == "" {
return
}
overviewSnapshotMu.Lock()
defer overviewSnapshotMu.Unlock()
if err := ensureOverviewSnapshotCacheLocked(); err != nil {
return
}
if overviewSnapshotCache == nil {
return
}
if _, ok := overviewSnapshotCache[path]; ok {
delete(overviewSnapshotCache, path)
_ = persistOverviewSnapshotLocked()
}
}
// prefetchOverviewCache warms overview cache in background.
func prefetchOverviewCache(ctx context.Context) {
entries := createOverviewEntries()
var needScan []string
for _, entry := range entries {
if size, err := loadStoredOverviewSize(entry.Path); err == nil && size > 0 {
continue
}
needScan = append(needScan, entry.Path)
}
if len(needScan) == 0 {
return
}
for _, path := range needScan {
select {
case <-ctx.Done():
return
default:
}
size, err := measureOverviewSize(path)
if err == nil && size > 0 {
_ = storeOverviewSize(path, size)
}
}
}

View File

@@ -1,107 +0,0 @@
package main
import (
"path/filepath"
"strings"
)
// isCleanableDir marks paths safe to delete manually (not handled by mo clean).
func isCleanableDir(path string) bool {
if path == "" {
return false
}
// Exclude paths mo clean already handles.
if isHandledByMoClean(path) {
return false
}
baseName := filepath.Base(path)
// Project dependencies and build outputs are safe.
if projectDependencyDirs[baseName] {
return true
}
return false
}
// isHandledByMoClean checks if a path is cleaned by mo clean.
func isHandledByMoClean(path string) bool {
cleanPaths := []string{
"/Library/Caches/",
"/Library/Logs/",
"/Library/Saved Application State/",
"/.Trash/",
"/Library/DiagnosticReports/",
}
for _, p := range cleanPaths {
if strings.Contains(path, p) {
return true
}
}
return false
}
// Project dependency and build directories.
var projectDependencyDirs = map[string]bool{
// JavaScript/Node.
"node_modules": true,
"bower_components": true,
".yarn": true,
".pnpm-store": true,
// Python.
"venv": true,
".venv": true,
"virtualenv": true,
"__pycache__": true,
".pytest_cache": true,
".mypy_cache": true,
".ruff_cache": true,
".tox": true,
".eggs": true,
"htmlcov": true,
".ipynb_checkpoints": true,
// Ruby.
"vendor": true,
".bundle": true,
// Java/Kotlin/Scala.
".gradle": true,
"out": true,
// Build outputs.
"build": true,
"dist": true,
"target": true,
".next": true,
".nuxt": true,
".output": true,
".parcel-cache": true,
".turbo": true,
".vite": true,
".nx": true,
"coverage": true,
".coverage": true,
".nyc_output": true,
// Frontend framework outputs.
".angular": true,
".svelte-kit": true,
".astro": true,
".docusaurus": true,
// Apple dev.
"DerivedData": true,
"Pods": true,
".build": true,
"Carthage": true,
".dart_tool": true,
// Other tools.
".terraform": true,
}

View File

@@ -1,248 +0,0 @@
package main
import "time"
const (
maxEntries = 30
maxLargeFiles = 30
barWidth = 24
minLargeFileSize = 100 << 20
defaultViewport = 12
overviewCacheTTL = 7 * 24 * time.Hour
overviewCacheFile = "overview_sizes.json"
duTimeout = 30 * time.Second
mdlsTimeout = 5 * time.Second
maxConcurrentOverview = 8
batchUpdateSize = 100
cacheModTimeGrace = 30 * time.Minute
// Worker pool limits.
minWorkers = 16
maxWorkers = 64
cpuMultiplier = 4
maxDirWorkers = 32
openCommandTimeout = 10 * time.Second
)
var foldDirs = map[string]bool{
// VCS.
".git": true,
".svn": true,
".hg": true,
// JavaScript/Node.
"node_modules": true,
".npm": true,
"_npx": true,
"_cacache": true,
"_logs": true,
"_locks": true,
"_quick": true,
"_libvips": true,
"_prebuilds": true,
"_update-notifier-last-checked": true,
".yarn": true,
".pnpm-store": true,
".next": true,
".nuxt": true,
"bower_components": true,
".vite": true,
".turbo": true,
".parcel-cache": true,
".nx": true,
".rush": true,
"tnpm": true,
".tnpm": true,
".bun": true,
".deno": true,
// Python.
"__pycache__": true,
".pytest_cache": true,
".mypy_cache": true,
".ruff_cache": true,
"venv": true,
".venv": true,
"virtualenv": true,
".tox": true,
"site-packages": true,
".eggs": true,
"*.egg-info": true,
".pyenv": true,
".poetry": true,
".pip": true,
".pipx": true,
// Ruby/Go/PHP (vendor), Java/Kotlin/Scala/Rust (target).
"vendor": true,
".bundle": true,
"gems": true,
".rbenv": true,
"target": true,
".gradle": true,
".m2": true,
".ivy2": true,
"out": true,
"pkg": true,
"composer.phar": true,
".composer": true,
".cargo": true,
// Build outputs.
"build": true,
"dist": true,
".output": true,
"coverage": true,
".coverage": true,
// IDE.
".idea": true,
".vscode": true,
".vs": true,
".fleet": true,
// Cache directories.
".cache": true,
"__MACOSX": true,
".DS_Store": true,
".Trash": true,
"Caches": true,
".Spotlight-V100": true,
".fseventsd": true,
".DocumentRevisions-V100": true,
".TemporaryItems": true,
"$RECYCLE.BIN": true,
".temp": true,
".tmp": true,
"_temp": true,
"_tmp": true,
".Homebrew": true,
".rustup": true,
".sdkman": true,
".nvm": true,
// macOS.
"Application Scripts": true,
"Saved Application State": true,
// iCloud.
"Mobile Documents": true,
// Containers.
".docker": true,
".containerd": true,
// Mobile development.
"Pods": true,
"DerivedData": true,
".build": true,
"xcuserdata": true,
"Carthage": true,
".dart_tool": true,
// Web frameworks.
".angular": true,
".svelte-kit": true,
".astro": true,
".solid": true,
// Databases.
".mysql": true,
".postgres": true,
"mongodb": true,
// Other.
".terraform": true,
".vagrant": true,
"tmp": true,
"temp": true,
}
var skipSystemDirs = map[string]bool{
"dev": true,
"tmp": true,
"private": true,
"cores": true,
"net": true,
"home": true,
"System": true,
"sbin": true,
"bin": true,
"etc": true,
"var": true,
"opt": false,
"usr": false,
"Volumes": true,
"Network": true,
".vol": true,
".Spotlight-V100": true,
".fseventsd": true,
".DocumentRevisions-V100": true,
".TemporaryItems": true,
".MobileBackups": true,
}
var defaultSkipDirs = map[string]bool{
"nfs": true,
"PHD": true,
"Permissions": true,
}
var skipExtensions = map[string]bool{
".go": true,
".js": true,
".ts": true,
".tsx": true,
".jsx": true,
".json": true,
".md": true,
".txt": true,
".yml": true,
".yaml": true,
".xml": true,
".html": true,
".css": true,
".scss": true,
".sass": true,
".less": true,
".py": true,
".rb": true,
".java": true,
".kt": true,
".rs": true,
".swift": true,
".m": true,
".mm": true,
".c": true,
".cpp": true,
".h": true,
".hpp": true,
".cs": true,
".sql": true,
".db": true,
".lock": true,
".gradle": true,
".mjs": true,
".cjs": true,
".coffee": true,
".dart": true,
".svelte": true,
".vue": true,
".nim": true,
".hx": true,
}
var spinnerFrames = []string{"|", "/", "-", "\\", "|", "/", "-", "\\"}
const (
colorPurple = "\033[0;35m"
colorPurpleBold = "\033[1;35m"
colorGray = "\033[0;90m"
colorRed = "\033[0;31m"
colorYellow = "\033[0;33m"
colorGreen = "\033[0;32m"
colorBlue = "\033[0;34m"
colorCyan = "\033[0;36m"
colorReset = "\033[0m"
colorBold = "\033[1m"
)

View File

@@ -1,146 +0,0 @@
package main
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync/atomic"
"time"
tea "github.com/charmbracelet/bubbletea"
)
const trashTimeout = 30 * time.Second
func deletePathCmd(path string, counter *int64) tea.Cmd {
return func() tea.Msg {
count, err := trashPathWithProgress(path, counter)
return deleteProgressMsg{
done: true,
err: err,
count: count,
path: path,
}
}
}
// deleteMultiplePathsCmd moves paths to Trash and aggregates results.
func deleteMultiplePathsCmd(paths []string, counter *int64) tea.Cmd {
return func() tea.Msg {
var totalCount int64
var errors []string
// Process deeper paths first to avoid parent/child conflicts.
pathsToDelete := append([]string(nil), paths...)
sort.Slice(pathsToDelete, func(i, j int) bool {
return strings.Count(pathsToDelete[i], string(filepath.Separator)) > strings.Count(pathsToDelete[j], string(filepath.Separator))
})
for _, path := range pathsToDelete {
count, err := trashPathWithProgress(path, counter)
totalCount += count
if err != nil {
if os.IsNotExist(err) {
continue
}
errors = append(errors, err.Error())
}
}
var resultErr error
if len(errors) > 0 {
resultErr = &multiDeleteError{errors: errors}
}
return deleteProgressMsg{
done: true,
err: resultErr,
count: totalCount,
path: "",
}
}
}
// multiDeleteError holds multiple deletion errors.
type multiDeleteError struct {
errors []string
}
func (e *multiDeleteError) Error() string {
if len(e.errors) == 1 {
return e.errors[0]
}
return strings.Join(e.errors[:min(3, len(e.errors))], "; ")
}
// trashPathWithProgress moves a path to Trash using Finder.
// This allows users to recover accidentally deleted files.
func trashPathWithProgress(root string, counter *int64) (int64, error) {
// Verify path exists (use Lstat to handle broken symlinks).
info, err := os.Lstat(root)
if err != nil {
return 0, err
}
// Count items for progress reporting.
var count int64
if info.IsDir() {
_ = filepath.WalkDir(root, func(_ string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
if !d.IsDir() {
count++
if counter != nil {
atomic.StoreInt64(counter, count)
}
}
return nil
})
} else {
count = 1
if counter != nil {
atomic.StoreInt64(counter, 1)
}
}
// Move to Trash using Finder AppleScript.
if err := moveToTrash(root); err != nil {
return 0, err
}
return count, nil
}
// moveToTrash uses macOS Finder to move a file/directory to Trash.
// This is the safest method as it uses the system's native trash mechanism.
func moveToTrash(path string) error {
absPath, err := filepath.Abs(path)
if err != nil {
return fmt.Errorf("failed to resolve path: %w", err)
}
// Escape path for AppleScript (handle quotes and backslashes).
escapedPath := strings.ReplaceAll(absPath, "\\", "\\\\")
escapedPath = strings.ReplaceAll(escapedPath, "\"", "\\\"")
script := fmt.Sprintf(`tell application "Finder" to delete POSIX file "%s"`, escapedPath)
ctx, cancel := context.WithTimeout(context.Background(), trashTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "osascript", "-e", script)
output, err := cmd.CombinedOutput()
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return fmt.Errorf("timeout moving to Trash")
}
return fmt.Errorf("failed to move to Trash: %s", strings.TrimSpace(string(output)))
}
return nil
}

View File

@@ -1,87 +0,0 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestTrashPathWithProgress(t *testing.T) {
// Skip in CI environments where Finder may not be available.
if os.Getenv("CI") != "" {
t.Skip("Skipping Finder-dependent test in CI")
}
parent := t.TempDir()
target := filepath.Join(parent, "target")
if err := os.MkdirAll(target, 0o755); err != nil {
t.Fatalf("create target: %v", err)
}
files := []string{
filepath.Join(target, "one.txt"),
filepath.Join(target, "two.txt"),
}
for _, f := range files {
if err := os.WriteFile(f, []byte("content"), 0o644); err != nil {
t.Fatalf("write %s: %v", f, err)
}
}
var counter int64
count, err := trashPathWithProgress(target, &counter)
if err != nil {
t.Fatalf("trashPathWithProgress returned error: %v", err)
}
if count != int64(len(files)) {
t.Fatalf("expected %d files trashed, got %d", len(files), count)
}
if _, err := os.Stat(target); !os.IsNotExist(err) {
t.Fatalf("expected target to be moved to Trash, stat err=%v", err)
}
}
func TestDeleteMultiplePathsCmdHandlesParentChild(t *testing.T) {
// Skip in CI environments where Finder may not be available.
if os.Getenv("CI") != "" {
t.Skip("Skipping Finder-dependent test in CI")
}
base := t.TempDir()
parent := filepath.Join(base, "parent")
child := filepath.Join(parent, "child")
// Structure: parent/fileA, parent/child/fileC.
if err := os.MkdirAll(child, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(parent, "fileA"), []byte("a"), 0o644); err != nil {
t.Fatalf("write fileA: %v", err)
}
if err := os.WriteFile(filepath.Join(child, "fileC"), []byte("c"), 0o644); err != nil {
t.Fatalf("write fileC: %v", err)
}
var counter int64
msg := deleteMultiplePathsCmd([]string{parent, child}, &counter)()
progress, ok := msg.(deleteProgressMsg)
if !ok {
t.Fatalf("expected deleteProgressMsg, got %T", msg)
}
if progress.err != nil {
t.Fatalf("unexpected error: %v", progress.err)
}
if progress.count != 2 {
t.Fatalf("expected 2 files trashed, got %d", progress.count)
}
if _, err := os.Stat(parent); !os.IsNotExist(err) {
t.Fatalf("expected parent to be moved to Trash, err=%v", err)
}
}
func TestMoveToTrashNonExistent(t *testing.T) {
err := moveToTrash("/nonexistent/path/that/does/not/exist")
if err == nil {
t.Fatal("expected error for non-existent path")
}
}

View File

@@ -1,247 +0,0 @@
package main
import (
"fmt"
"os"
"strings"
"time"
)
func displayPath(path string) string {
home, err := os.UserHomeDir()
if err != nil || home == "" {
return path
}
if strings.HasPrefix(path, home) {
return strings.Replace(path, home, "~", 1)
}
return path
}
// truncateMiddle trims the middle, keeping head and tail.
func truncateMiddle(s string, maxWidth int) string {
runes := []rune(s)
currentWidth := displayWidth(s)
if currentWidth <= maxWidth {
return s
}
if maxWidth < 10 {
width := 0
for i, r := range runes {
width += runeWidth(r)
if width > maxWidth {
return string(runes[:i])
}
}
return s
}
targetHeadWidth := (maxWidth - 3) / 3
targetTailWidth := maxWidth - 3 - targetHeadWidth
headWidth := 0
headIdx := 0
for i, r := range runes {
w := runeWidth(r)
if headWidth+w > targetHeadWidth {
break
}
headWidth += w
headIdx = i + 1
}
tailWidth := 0
tailIdx := len(runes)
for i := len(runes) - 1; i >= 0; i-- {
w := runeWidth(runes[i])
if tailWidth+w > targetTailWidth {
break
}
tailWidth += w
tailIdx = i
}
return string(runes[:headIdx]) + "..." + string(runes[tailIdx:])
}
func formatNumber(n int64) string {
if n < 1000 {
return fmt.Sprintf("%d", n)
}
if n < 1000000 {
return fmt.Sprintf("%.1fk", float64(n)/1000)
}
return fmt.Sprintf("%.1fM", float64(n)/1000000)
}
func humanizeBytes(size int64) string {
if size < 0 {
return "0 B"
}
const unit = 1024
if size < unit {
return fmt.Sprintf("%d B", size)
}
div, exp := int64(unit), 0
for n := size / unit; n >= unit; n /= unit {
div *= unit
exp++
}
value := float64(size) / float64(div)
return fmt.Sprintf("%.1f %cB", value, "KMGTPE"[exp])
}
func coloredProgressBar(value, maxValue int64, percent float64) string {
if maxValue <= 0 {
return colorGray + strings.Repeat("░", barWidth) + colorReset
}
filled := min(int((value*int64(barWidth))/maxValue), barWidth)
var barColor string
if percent >= 50 {
barColor = colorRed
} else if percent >= 20 {
barColor = colorYellow
} else if percent >= 5 {
barColor = colorBlue
} else {
barColor = colorGreen
}
var bar strings.Builder
bar.WriteString(barColor)
for i := range barWidth {
if i < filled {
if i < filled-1 {
bar.WriteString("█")
} else {
remainder := (value * int64(barWidth)) % maxValue
if remainder > maxValue/2 {
bar.WriteString("█")
} else if remainder > maxValue/4 {
bar.WriteString("▓")
} else {
bar.WriteString("▒")
}
}
} else {
bar.WriteString(colorGray + "░" + barColor)
}
}
return bar.String() + colorReset
}
// runeWidth returns display width for wide characters and emoji.
func runeWidth(r rune) int {
if r >= 0x4E00 && r <= 0x9FFF || // CJK Unified Ideographs
r >= 0x3400 && r <= 0x4DBF || // CJK Extension A
r >= 0x20000 && r <= 0x2A6DF || // CJK Extension B
r >= 0x2A700 && r <= 0x2B73F || // CJK Extension C
r >= 0x2B740 && r <= 0x2B81F || // CJK Extension D
r >= 0x2B820 && r <= 0x2CEAF || // CJK Extension E
r >= 0x3040 && r <= 0x30FF || // Hiragana and Katakana
r >= 0x31F0 && r <= 0x31FF || // Katakana Phonetic Extensions
r >= 0xAC00 && r <= 0xD7AF || // Hangul Syllables
r >= 0xFF00 && r <= 0xFFEF || // Fullwidth Forms
r >= 0x1F300 && r <= 0x1F6FF || // Miscellaneous Symbols and Pictographs (includes Transport)
r >= 0x1F900 && r <= 0x1F9FF || // Supplemental Symbols and Pictographs
r >= 0x2600 && r <= 0x26FF || // Miscellaneous Symbols
r >= 0x2700 && r <= 0x27BF || // Dingbats
r >= 0xFE10 && r <= 0xFE1F || // Vertical Forms
r >= 0x1F000 && r <= 0x1F02F { // Mahjong Tiles
return 2
}
return 1
}
func displayWidth(s string) int {
width := 0
for _, r := range s {
width += runeWidth(r)
}
return width
}
// calculateNameWidth computes name column width from terminal width.
func calculateNameWidth(termWidth int) int {
const fixedWidth = 61
available := termWidth - fixedWidth
if available < 24 {
return 24
}
if available > 60 {
return 60
}
return available
}
func trimNameWithWidth(name string, maxWidth int) string {
const (
ellipsis = "..."
ellipsisWidth = 3
)
runes := []rune(name)
widths := make([]int, len(runes))
for i, r := range runes {
widths[i] = runeWidth(r)
}
currentWidth := 0
for i, w := range widths {
if currentWidth+w > maxWidth {
subWidth := currentWidth
j := i
for j > 0 && subWidth+ellipsisWidth > maxWidth {
j--
subWidth -= widths[j]
}
if j == 0 {
return ellipsis
}
return string(runes[:j]) + ellipsis
}
currentWidth += w
}
return name
}
func padName(name string, targetWidth int) string {
currentWidth := displayWidth(name)
if currentWidth >= targetWidth {
return name
}
return name + strings.Repeat(" ", targetWidth-currentWidth)
}
// formatUnusedTime formats time since last access.
func formatUnusedTime(lastAccess time.Time) string {
if lastAccess.IsZero() {
return ""
}
duration := time.Since(lastAccess)
days := int(duration.Hours() / 24)
if days < 90 {
return ""
}
months := days / 30
years := days / 365
if years >= 2 {
return fmt.Sprintf(">%dyr", years)
} else if years >= 1 {
return ">1yr"
} else if months >= 3 {
return fmt.Sprintf(">%dmo", months)
}
return ""
}

View File

@@ -1,309 +0,0 @@
package main
import (
"strings"
"testing"
)
func TestRuneWidth(t *testing.T) {
tests := []struct {
name string
input rune
want int
}{
{"ASCII letter", 'a', 1},
{"ASCII digit", '5', 1},
{"Chinese character", '中', 2},
{"Japanese hiragana", 'あ', 2},
{"Korean hangul", '한', 2},
{"CJK ideograph", '語', 2},
{"Full-width number", '', 2},
{"ASCII space", ' ', 1},
{"Tab", '\t', 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := runeWidth(tt.input); got != tt.want {
t.Errorf("runeWidth(%q) = %d, want %d", tt.input, got, tt.want)
}
})
}
}
func TestDisplayWidth(t *testing.T) {
tests := []struct {
name string
input string
want int
}{
{"Empty string", "", 0},
{"ASCII only", "hello", 5},
{"Chinese only", "你好", 4},
{"Mixed ASCII and CJK", "hello世界", 9}, // 5 + 4
{"Path with CJK", "/Users/张三/文件", 16}, // 7 (ASCII) + 4 (张三) + 4 (文件) + 1 (/) = 16
{"Full-width chars", "", 6},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := displayWidth(tt.input); got != tt.want {
t.Errorf("displayWidth(%q) = %d, want %d", tt.input, got, tt.want)
}
})
}
}
func TestHumanizeBytes(t *testing.T) {
tests := []struct {
input int64
want string
}{
{-100, "0 B"},
{0, "0 B"},
{512, "512 B"},
{1023, "1023 B"},
{1024, "1.0 KB"},
{1536, "1.5 KB"},
{10240, "10.0 KB"},
{1048576, "1.0 MB"},
{1572864, "1.5 MB"},
{1073741824, "1.0 GB"},
{1099511627776, "1.0 TB"},
{1125899906842624, "1.0 PB"},
}
for _, tt := range tests {
got := humanizeBytes(tt.input)
if got != tt.want {
t.Errorf("humanizeBytes(%d) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestFormatNumber(t *testing.T) {
tests := []struct {
input int64
want string
}{
{0, "0"},
{500, "500"},
{999, "999"},
{1000, "1.0k"},
{1500, "1.5k"},
{999999, "1000.0k"},
{1000000, "1.0M"},
{1500000, "1.5M"},
}
for _, tt := range tests {
got := formatNumber(tt.input)
if got != tt.want {
t.Errorf("formatNumber(%d) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestTruncateMiddle(t *testing.T) {
tests := []struct {
name string
input string
maxWidth int
check func(t *testing.T, result string)
}{
{
name: "No truncation needed",
input: "short",
maxWidth: 10,
check: func(t *testing.T, result string) {
if result != "short" {
t.Errorf("Should not truncate short string, got %q", result)
}
},
},
{
name: "Truncate long ASCII",
input: "verylongfilename.txt",
maxWidth: 15,
check: func(t *testing.T, result string) {
if !strings.Contains(result, "...") {
t.Errorf("Truncated string should contain '...', got %q", result)
}
if displayWidth(result) > 15 {
t.Errorf("Truncated width %d exceeds max %d", displayWidth(result), 15)
}
},
},
{
name: "Truncate with CJK characters",
input: "非常长的中文文件名称.txt",
maxWidth: 20,
check: func(t *testing.T, result string) {
if !strings.Contains(result, "...") {
t.Errorf("Should truncate CJK string, got %q", result)
}
if displayWidth(result) > 20 {
t.Errorf("Truncated width %d exceeds max %d", displayWidth(result), 20)
}
},
},
{
name: "Very small width",
input: "longname",
maxWidth: 5,
check: func(t *testing.T, result string) {
if displayWidth(result) > 5 {
t.Errorf("Width %d exceeds max %d", displayWidth(result), 5)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := truncateMiddle(tt.input, tt.maxWidth)
tt.check(t, result)
})
}
}
func TestDisplayPath(t *testing.T) {
tests := []struct {
name string
setup func() string
check func(t *testing.T, result string)
}{
{
name: "Replace home directory",
setup: func() string {
home := t.TempDir()
t.Setenv("HOME", home)
return home + "/Documents/file.txt"
},
check: func(t *testing.T, result string) {
if !strings.HasPrefix(result, "~/") {
t.Errorf("Expected path to start with ~/, got %q", result)
}
if !strings.HasSuffix(result, "Documents/file.txt") {
t.Errorf("Expected path to end with Documents/file.txt, got %q", result)
}
},
},
{
name: "Keep absolute path outside home",
setup: func() string {
t.Setenv("HOME", "/Users/test")
return "/var/log/system.log"
},
check: func(t *testing.T, result string) {
if result != "/var/log/system.log" {
t.Errorf("Expected unchanged path, got %q", result)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := tt.setup()
result := displayPath(path)
tt.check(t, result)
})
}
}
func TestPadName(t *testing.T) {
tests := []struct {
name string
input string
targetWidth int
wantWidth int
}{
{"Pad ASCII", "test", 10, 10},
{"No padding needed", "longname", 5, 8},
{"Pad CJK", "中文", 10, 10},
{"Mixed CJK and ASCII", "hello世", 15, 15},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := padName(tt.input, tt.targetWidth)
gotWidth := displayWidth(result)
if gotWidth < tt.wantWidth && displayWidth(tt.input) < tt.targetWidth {
t.Errorf("padName(%q, %d) width = %d, want >= %d", tt.input, tt.targetWidth, gotWidth, tt.wantWidth)
}
})
}
}
func TestTrimNameWithWidth(t *testing.T) {
tests := []struct {
name string
input string
maxWidth int
check func(t *testing.T, result string)
}{
{
name: "Trim ASCII name",
input: "verylongfilename.txt",
maxWidth: 10,
check: func(t *testing.T, result string) {
if displayWidth(result) > 10 {
t.Errorf("Width exceeds max: %d > 10", displayWidth(result))
}
if !strings.HasSuffix(result, "...") {
t.Errorf("Expected ellipsis, got %q", result)
}
},
},
{
name: "Trim CJK name",
input: "很长的文件名称.txt",
maxWidth: 12,
check: func(t *testing.T, result string) {
if displayWidth(result) > 12 {
t.Errorf("Width exceeds max: %d > 12", displayWidth(result))
}
},
},
{
name: "No trimming needed",
input: "short.txt",
maxWidth: 20,
check: func(t *testing.T, result string) {
if result != "short.txt" {
t.Errorf("Should not trim, got %q", result)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := trimNameWithWidth(tt.input, tt.maxWidth)
tt.check(t, result)
})
}
}
func TestCalculateNameWidth(t *testing.T) {
tests := []struct {
termWidth int
wantMin int
wantMax int
}{
{80, 19, 60}, // 80 - 61 = 19
{120, 59, 60}, // 120 - 61 = 59
{200, 60, 60}, // Capped at 60
{70, 24, 60}, // Below minimum, use 24
{50, 24, 60}, // Very small, use minimum
}
for _, tt := range tests {
got := calculateNameWidth(tt.termWidth)
if got < tt.wantMin || got > tt.wantMax {
t.Errorf("calculateNameWidth(%d) = %d, want between %d and %d",
tt.termWidth, got, tt.wantMin, tt.wantMax)
}
}
}

View File

@@ -1,39 +0,0 @@
package main
// entryHeap is a min-heap of dirEntry used to keep Top N largest entries.
type entryHeap []dirEntry
func (h entryHeap) Len() int { return len(h) }
func (h entryHeap) Less(i, j int) bool { return h[i].Size < h[j].Size }
func (h entryHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *entryHeap) Push(x any) {
*h = append(*h, x.(dirEntry))
}
func (h *entryHeap) Pop() any {
old := *h
n := len(old)
x := old[n-1]
*h = old[0 : n-1]
return x
}
// largeFileHeap is a min-heap for fileEntry.
type largeFileHeap []fileEntry
func (h largeFileHeap) Len() int { return len(h) }
func (h largeFileHeap) Less(i, j int) bool { return h[i].Size < h[j].Size }
func (h largeFileHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *largeFileHeap) Push(x any) {
*h = append(*h, x.(fileEntry))
}
func (h *largeFileHeap) Pop() any {
old := *h
n := len(old)
x := old[n-1]
*h = old[0 : n-1]
return x
}

File diff suppressed because it is too large Load Diff

164
cmd/analyze/main_test.go Normal file
View File

@@ -0,0 +1,164 @@
//go:build windows
package main
import (
"os"
"path/filepath"
"testing"
)
func TestFormatBytes(t *testing.T) {
tests := []struct {
input int64
expected string
}{
{0, "0 B"},
{512, "512 B"},
{1024, "1.0 KB"},
{1536, "1.5 KB"},
{1048576, "1.0 MB"},
{1073741824, "1.0 GB"},
{1099511627776, "1.0 TB"},
}
for _, test := range tests {
result := formatBytes(test.input)
if result != test.expected {
t.Errorf("formatBytes(%d) = %s, expected %s", test.input, result, test.expected)
}
}
}
func TestTruncatePath(t *testing.T) {
tests := []struct {
input string
maxLen int
expected string
}{
{"C:\\short", 20, "C:\\short"},
{"C:\\this\\is\\a\\very\\long\\path\\that\\should\\be\\truncated", 30, "...ong\\path\\that\\should\\be\\truncated"},
}
for _, test := range tests {
result := truncatePath(test.input, test.maxLen)
if len(result) > test.maxLen && test.maxLen < len(test.input) {
// For truncated paths, just verify length constraint
if len(result) > test.maxLen+10 { // Allow some flexibility
t.Errorf("truncatePath(%s, %d) length = %d, expected <= %d", test.input, test.maxLen, len(result), test.maxLen)
}
}
}
}
func TestCleanablePatterns(t *testing.T) {
expectedCleanable := []string{
"node_modules",
"vendor",
".venv",
"venv",
"__pycache__",
"target",
"build",
"dist",
}
for _, pattern := range expectedCleanable {
if !cleanablePatterns[pattern] {
t.Errorf("Expected %s to be in cleanablePatterns", pattern)
}
}
}
func TestSkipPatterns(t *testing.T) {
expectedSkip := []string{
"$Recycle.Bin",
"System Volume Information",
"Windows",
"Program Files",
}
for _, pattern := range expectedSkip {
if !skipPatterns[pattern] {
t.Errorf("Expected %s to be in skipPatterns", pattern)
}
}
}
func TestCalculateDirSize(t *testing.T) {
// Create a temp directory with known content
tmpDir, err := os.MkdirTemp("", "mole_test_*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Create a test file with known size
testFile := filepath.Join(tmpDir, "test.txt")
content := []byte("Hello, World!") // 13 bytes
if err := os.WriteFile(testFile, content, 0644); err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
size := calculateDirSize(tmpDir)
if size != int64(len(content)) {
t.Errorf("calculateDirSize() = %d, expected %d", size, len(content))
}
}
func TestNewModel(t *testing.T) {
model := newModel("C:\\")
if model.path != "C:\\" {
t.Errorf("newModel path = %s, expected C:\\", model.path)
}
if !model.scanning {
t.Error("newModel should start in scanning state")
}
if model.multiSelected == nil {
t.Error("newModel multiSelected should be initialized")
}
if model.cache == nil {
t.Error("newModel cache should be initialized")
}
}
func TestScanDirectory(t *testing.T) {
// Create a temp directory with known structure
tmpDir, err := os.MkdirTemp("", "mole_scan_test_*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Create subdirectory
subDir := filepath.Join(tmpDir, "subdir")
if err := os.Mkdir(subDir, 0755); err != nil {
t.Fatalf("Failed to create subdir: %v", err)
}
// Create test files
testFile1 := filepath.Join(tmpDir, "file1.txt")
testFile2 := filepath.Join(subDir, "file2.txt")
os.WriteFile(testFile1, []byte("content1"), 0644)
os.WriteFile(testFile2, []byte("content2"), 0644)
entries, largeFiles, totalSize, err := scanDirectory(tmpDir)
if err != nil {
t.Fatalf("scanDirectory error: %v", err)
}
if len(entries) != 2 { // subdir + file1.txt
t.Errorf("Expected 2 entries, got %d", len(entries))
}
if totalSize == 0 {
t.Error("totalSize should be greater than 0")
}
// No large files in this test
_ = largeFiles
}

View File

@@ -1,663 +0,0 @@
package main
import (
"bytes"
"container/heap"
"context"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"golang.org/x/sync/singleflight"
)
var scanGroup singleflight.Group
func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *int64, currentPath *string) (scanResult, error) {
children, err := os.ReadDir(root)
if err != nil {
return scanResult{}, err
}
var total int64
// Keep Top N heaps.
entriesHeap := &entryHeap{}
heap.Init(entriesHeap)
largeFilesHeap := &largeFileHeap{}
heap.Init(largeFilesHeap)
// Worker pool sized for I/O-bound scanning.
numWorkers := max(runtime.NumCPU()*cpuMultiplier, minWorkers)
if numWorkers > maxWorkers {
numWorkers = maxWorkers
}
if numWorkers > len(children) {
numWorkers = len(children)
}
if numWorkers < 1 {
numWorkers = 1
}
sem := make(chan struct{}, numWorkers)
var wg sync.WaitGroup
// Collect results via channels.
entryChan := make(chan dirEntry, len(children))
largeFileChan := make(chan fileEntry, maxLargeFiles*2)
var collectorWg sync.WaitGroup
collectorWg.Add(2)
go func() {
defer collectorWg.Done()
for entry := range entryChan {
if entriesHeap.Len() < maxEntries {
heap.Push(entriesHeap, entry)
} else if entry.Size > (*entriesHeap)[0].Size {
heap.Pop(entriesHeap)
heap.Push(entriesHeap, entry)
}
}
}()
go func() {
defer collectorWg.Done()
for file := range largeFileChan {
if largeFilesHeap.Len() < maxLargeFiles {
heap.Push(largeFilesHeap, file)
} else if file.Size > (*largeFilesHeap)[0].Size {
heap.Pop(largeFilesHeap)
heap.Push(largeFilesHeap, file)
}
}
}()
isRootDir := root == "/"
home := os.Getenv("HOME")
isHomeDir := home != "" && root == home
for _, child := range children {
fullPath := filepath.Join(root, child.Name())
// Skip symlinks to avoid following unexpected targets.
if child.Type()&fs.ModeSymlink != 0 {
targetInfo, err := os.Stat(fullPath)
isDir := false
if err == nil && targetInfo.IsDir() {
isDir = true
}
// Count link size only to avoid double-counting targets.
info, err := child.Info()
if err != nil {
continue
}
size := getActualFileSize(fullPath, info)
atomic.AddInt64(&total, size)
entryChan <- dirEntry{
Name: child.Name() + " →",
Path: fullPath,
Size: size,
IsDir: isDir,
LastAccess: getLastAccessTimeFromInfo(info),
}
continue
}
if child.IsDir() {
if defaultSkipDirs[child.Name()] {
continue
}
// Skip system dirs at root.
if isRootDir && skipSystemDirs[child.Name()] {
continue
}
// ~/Library is scanned separately; reuse cache when possible.
if isHomeDir && child.Name() == "Library" {
wg.Add(1)
go func(name, path string) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
var size int64
if cached, err := loadStoredOverviewSize(path); err == nil && cached > 0 {
size = cached
} else if cached, err := loadCacheFromDisk(path); err == nil {
size = cached.TotalSize
} else {
size = calculateDirSizeConcurrent(path, largeFileChan, filesScanned, dirsScanned, bytesScanned, currentPath)
}
atomic.AddInt64(&total, size)
atomic.AddInt64(dirsScanned, 1)
entryChan <- dirEntry{
Name: name,
Path: path,
Size: size,
IsDir: true,
LastAccess: time.Time{},
}
}(child.Name(), fullPath)
continue
}
// Folded dirs: fast size without expanding.
if shouldFoldDirWithPath(child.Name(), fullPath) {
wg.Add(1)
go func(name, path string) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
size, err := getDirectorySizeFromDu(path)
if err != nil || size <= 0 {
size = calculateDirSizeFast(path, filesScanned, dirsScanned, bytesScanned, currentPath)
}
atomic.AddInt64(&total, size)
atomic.AddInt64(dirsScanned, 1)
entryChan <- dirEntry{
Name: name,
Path: path,
Size: size,
IsDir: true,
LastAccess: time.Time{},
}
}(child.Name(), fullPath)
continue
}
wg.Add(1)
go func(name, path string) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
size := calculateDirSizeConcurrent(path, largeFileChan, filesScanned, dirsScanned, bytesScanned, currentPath)
atomic.AddInt64(&total, size)
atomic.AddInt64(dirsScanned, 1)
entryChan <- dirEntry{
Name: name,
Path: path,
Size: size,
IsDir: true,
LastAccess: time.Time{},
}
}(child.Name(), fullPath)
continue
}
info, err := child.Info()
if err != nil {
continue
}
// Actual disk usage for sparse/cloud files.
size := getActualFileSize(fullPath, info)
atomic.AddInt64(&total, size)
atomic.AddInt64(filesScanned, 1)
atomic.AddInt64(bytesScanned, size)
entryChan <- dirEntry{
Name: child.Name(),
Path: fullPath,
Size: size,
IsDir: false,
LastAccess: getLastAccessTimeFromInfo(info),
}
// Track large files only.
if !shouldSkipFileForLargeTracking(fullPath) && size >= minLargeFileSize {
largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size}
}
}
wg.Wait()
// Close channels and wait for collectors.
close(entryChan)
close(largeFileChan)
collectorWg.Wait()
// Convert heaps to sorted slices (descending).
entries := make([]dirEntry, entriesHeap.Len())
for i := len(entries) - 1; i >= 0; i-- {
entries[i] = heap.Pop(entriesHeap).(dirEntry)
}
largeFiles := make([]fileEntry, largeFilesHeap.Len())
for i := len(largeFiles) - 1; i >= 0; i-- {
largeFiles[i] = heap.Pop(largeFilesHeap).(fileEntry)
}
// Use Spotlight for large files when available.
if spotlightFiles := findLargeFilesWithSpotlight(root, minLargeFileSize); len(spotlightFiles) > 0 {
largeFiles = spotlightFiles
}
return scanResult{
Entries: entries,
LargeFiles: largeFiles,
TotalSize: total,
TotalFiles: atomic.LoadInt64(filesScanned),
}, nil
}
func shouldFoldDirWithPath(name, path string) bool {
if foldDirs[name] {
return true
}
// Handle npm cache structure.
if strings.Contains(path, "/.npm/") || strings.Contains(path, "/.tnpm/") {
parent := filepath.Base(filepath.Dir(path))
if parent == ".npm" || parent == ".tnpm" || strings.HasPrefix(parent, "_") {
return true
}
if len(name) == 1 {
return true
}
}
return false
}
func shouldSkipFileForLargeTracking(path string) bool {
ext := strings.ToLower(filepath.Ext(path))
return skipExtensions[ext]
}
// calculateDirSizeFast performs concurrent dir sizing using os.ReadDir.
func calculateDirSizeFast(root string, filesScanned, dirsScanned, bytesScanned *int64, currentPath *string) int64 {
var total int64
var wg sync.WaitGroup
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
concurrency := min(runtime.NumCPU()*4, 64)
sem := make(chan struct{}, concurrency)
var walk func(string)
walk = func(dirPath string) {
select {
case <-ctx.Done():
return
default:
}
if currentPath != nil && atomic.LoadInt64(filesScanned)%int64(batchUpdateSize) == 0 {
*currentPath = dirPath
}
entries, err := os.ReadDir(dirPath)
if err != nil {
return
}
var localBytes, localFiles int64
for _, entry := range entries {
if entry.IsDir() {
wg.Add(1)
subDir := filepath.Join(dirPath, entry.Name())
go func(p string) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
walk(p)
}(subDir)
atomic.AddInt64(dirsScanned, 1)
} else {
info, err := entry.Info()
if err == nil {
size := getActualFileSize(filepath.Join(dirPath, entry.Name()), info)
localBytes += size
localFiles++
}
}
}
if localBytes > 0 {
atomic.AddInt64(&total, localBytes)
atomic.AddInt64(bytesScanned, localBytes)
}
if localFiles > 0 {
atomic.AddInt64(filesScanned, localFiles)
}
}
walk(root)
wg.Wait()
return total
}
// Use Spotlight (mdfind) to quickly find large files.
func findLargeFilesWithSpotlight(root string, minSize int64) []fileEntry {
query := fmt.Sprintf("kMDItemFSSize >= %d", minSize)
ctx, cancel := context.WithTimeout(context.Background(), mdlsTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "mdfind", "-onlyin", root, query)
output, err := cmd.Output()
if err != nil {
return nil
}
var files []fileEntry
for line := range strings.Lines(strings.TrimSpace(string(output))) {
if line == "" {
continue
}
// Filter code files first (cheap).
if shouldSkipFileForLargeTracking(line) {
continue
}
// Filter folded directories (cheap string check).
if isInFoldedDir(line) {
continue
}
info, err := os.Lstat(line)
if err != nil {
continue
}
if info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
continue
}
// Actual disk usage for sparse/cloud files.
actualSize := getActualFileSize(line, info)
files = append(files, fileEntry{
Name: filepath.Base(line),
Path: line,
Size: actualSize,
})
}
// Sort by size (descending).
sort.Slice(files, func(i, j int) bool {
return files[i].Size > files[j].Size
})
if len(files) > maxLargeFiles {
files = files[:maxLargeFiles]
}
return files
}
// isInFoldedDir checks if a path is inside a folded directory.
func isInFoldedDir(path string) bool {
parts := strings.SplitSeq(path, string(os.PathSeparator))
for part := range parts {
if foldDirs[part] {
return true
}
}
return false
}
func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, filesScanned, dirsScanned, bytesScanned *int64, currentPath *string) int64 {
children, err := os.ReadDir(root)
if err != nil {
return 0
}
var total int64
var wg sync.WaitGroup
// Limit concurrent subdirectory scans.
maxConcurrent := min(runtime.NumCPU()*2, maxDirWorkers)
sem := make(chan struct{}, maxConcurrent)
for _, child := range children {
fullPath := filepath.Join(root, child.Name())
if child.Type()&fs.ModeSymlink != 0 {
info, err := child.Info()
if err != nil {
continue
}
size := getActualFileSize(fullPath, info)
total += size
atomic.AddInt64(filesScanned, 1)
atomic.AddInt64(bytesScanned, size)
continue
}
if child.IsDir() {
if shouldFoldDirWithPath(child.Name(), fullPath) {
wg.Add(1)
go func(path string) {
defer wg.Done()
size, err := getDirectorySizeFromDu(path)
if err == nil && size > 0 {
atomic.AddInt64(&total, size)
atomic.AddInt64(bytesScanned, size)
atomic.AddInt64(dirsScanned, 1)
}
}(fullPath)
continue
}
wg.Add(1)
go func(path string) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
size := calculateDirSizeConcurrent(path, largeFileChan, filesScanned, dirsScanned, bytesScanned, currentPath)
atomic.AddInt64(&total, size)
atomic.AddInt64(dirsScanned, 1)
}(fullPath)
continue
}
info, err := child.Info()
if err != nil {
continue
}
size := getActualFileSize(fullPath, info)
total += size
atomic.AddInt64(filesScanned, 1)
atomic.AddInt64(bytesScanned, size)
if !shouldSkipFileForLargeTracking(fullPath) && size >= minLargeFileSize {
largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size}
}
// Update current path occasionally to prevent UI jitter.
if currentPath != nil && atomic.LoadInt64(filesScanned)%int64(batchUpdateSize) == 0 {
*currentPath = fullPath
}
}
wg.Wait()
return total
}
// measureOverviewSize calculates the size of a directory using multiple strategies.
// When scanning Home, it excludes ~/Library to avoid duplicate counting.
func measureOverviewSize(path string) (int64, error) {
if path == "" {
return 0, fmt.Errorf("empty path")
}
path = filepath.Clean(path)
if !filepath.IsAbs(path) {
return 0, fmt.Errorf("path must be absolute: %s", path)
}
if _, err := os.Stat(path); err != nil {
return 0, fmt.Errorf("cannot access path: %v", err)
}
// Determine if we should exclude ~/Library (when scanning Home)
home := os.Getenv("HOME")
excludePath := ""
if home != "" && path == home {
excludePath = filepath.Join(home, "Library")
}
if cached, err := loadStoredOverviewSize(path); err == nil && cached > 0 {
return cached, nil
}
if duSize, err := getDirectorySizeFromDuWithExclude(path, excludePath); err == nil && duSize > 0 {
_ = storeOverviewSize(path, duSize)
return duSize, nil
}
if logicalSize, err := getDirectoryLogicalSizeWithExclude(path, excludePath); err == nil && logicalSize > 0 {
_ = storeOverviewSize(path, logicalSize)
return logicalSize, nil
}
if cached, err := loadCacheFromDisk(path); err == nil {
_ = storeOverviewSize(path, cached.TotalSize)
return cached.TotalSize, nil
}
return 0, fmt.Errorf("unable to measure directory size with fast methods")
}
func getDirectorySizeFromDu(path string) (int64, error) {
return getDirectorySizeFromDuWithExclude(path, "")
}
func getDirectorySizeFromDuWithExclude(path string, excludePath string) (int64, error) {
runDuSize := func(target string) (int64, error) {
if _, err := os.Stat(target); err != nil {
return 0, err
}
ctx, cancel := context.WithTimeout(context.Background(), duTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "du", "-sk", target)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if ctx.Err() == context.DeadlineExceeded {
return 0, fmt.Errorf("du timeout after %v", duTimeout)
}
if stderr.Len() > 0 {
return 0, fmt.Errorf("du failed: %v (%s)", err, stderr.String())
}
return 0, fmt.Errorf("du failed: %v", err)
}
fields := strings.Fields(stdout.String())
if len(fields) == 0 {
return 0, fmt.Errorf("du output empty")
}
kb, err := strconv.ParseInt(fields[0], 10, 64)
if err != nil {
return 0, fmt.Errorf("failed to parse du output: %v", err)
}
if kb <= 0 {
return 0, fmt.Errorf("du size invalid: %d", kb)
}
return kb * 1024, nil
}
// When excluding a path (e.g., ~/Library), subtract only that exact directory instead of ignoring every "Library"
if excludePath != "" {
totalSize, err := runDuSize(path)
if err != nil {
return 0, err
}
excludeSize, err := runDuSize(excludePath)
if err != nil {
if !os.IsNotExist(err) {
return 0, err
}
excludeSize = 0
}
if excludeSize > totalSize {
excludeSize = 0
}
return totalSize - excludeSize, nil
}
return runDuSize(path)
}
func getDirectoryLogicalSizeWithExclude(path string, excludePath string) (int64, error) {
var total int64
err := filepath.WalkDir(path, func(p string, d fs.DirEntry, err error) error {
if err != nil {
if os.IsPermission(err) {
return filepath.SkipDir
}
return nil
}
// Skip excluded path
if excludePath != "" && p == excludePath {
return filepath.SkipDir
}
if d.IsDir() {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
total += getActualFileSize(p, info)
return nil
})
if err != nil && err != filepath.SkipDir {
return 0, err
}
return total, nil
}
func getActualFileSize(_ string, info fs.FileInfo) int64 {
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return info.Size()
}
actualSize := stat.Blocks * 512
if actualSize < info.Size() {
return actualSize
}
return info.Size()
}
func getLastAccessTime(path string) time.Time {
info, err := os.Stat(path)
if err != nil {
return time.Time{}
}
return getLastAccessTimeFromInfo(info)
}
func getLastAccessTimeFromInfo(info fs.FileInfo) time.Time {
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return time.Time{}
}
return time.Unix(stat.Atimespec.Sec, stat.Atimespec.Nsec)
}

View File

@@ -1,45 +0,0 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func writeFileWithSize(t *testing.T, path string, size int) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", path, err)
}
content := make([]byte, size)
if err := os.WriteFile(path, content, 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func TestGetDirectoryLogicalSizeWithExclude(t *testing.T) {
base := t.TempDir()
homeFile := filepath.Join(base, "fileA")
libFile := filepath.Join(base, "Library", "fileB")
projectLibFile := filepath.Join(base, "Projects", "Library", "fileC")
writeFileWithSize(t, homeFile, 100)
writeFileWithSize(t, libFile, 200)
writeFileWithSize(t, projectLibFile, 300)
total, err := getDirectoryLogicalSizeWithExclude(base, "")
if err != nil {
t.Fatalf("getDirectoryLogicalSizeWithExclude (no exclude) error: %v", err)
}
if total != 600 {
t.Fatalf("expected total 600 bytes, got %d", total)
}
excluding, err := getDirectoryLogicalSizeWithExclude(base, filepath.Join(base, "Library"))
if err != nil {
t.Fatalf("getDirectoryLogicalSizeWithExclude (exclude Library) error: %v", err)
}
if excluding != 400 {
t.Fatalf("expected 400 bytes when excluding top-level Library, got %d", excluding)
}
}

View File

@@ -1,428 +0,0 @@
//go:build darwin
package main
import (
"fmt"
"strings"
"sync/atomic"
)
// View renders the TUI.
func (m model) View() string {
var b strings.Builder
fmt.Fprintln(&b)
if m.inOverviewMode() {
fmt.Fprintf(&b, "%sAnalyze Disk%s\n", colorPurpleBold, colorReset)
if m.overviewScanning {
allPending := true
for _, entry := range m.entries {
if entry.Size >= 0 {
allPending = false
break
}
}
if allPending {
fmt.Fprintf(&b, "%s%s%s%s Analyzing disk usage, please wait...%s\n",
colorCyan, colorBold,
spinnerFrames[m.spinner],
colorReset, colorReset)
return b.String()
} else {
fmt.Fprintf(&b, "%sSelect a location to explore:%s ", colorGray, colorReset)
fmt.Fprintf(&b, "%s%s%s%s Scanning...\n\n", colorCyan, colorBold, spinnerFrames[m.spinner], colorReset)
}
} else {
hasPending := false
for _, entry := range m.entries {
if entry.Size < 0 {
hasPending = true
break
}
}
if hasPending {
fmt.Fprintf(&b, "%sSelect a location to explore:%s ", colorGray, colorReset)
fmt.Fprintf(&b, "%s%s%s%s Scanning...\n\n", colorCyan, colorBold, spinnerFrames[m.spinner], colorReset)
} else {
fmt.Fprintf(&b, "%sSelect a location to explore:%s\n\n", colorGray, colorReset)
}
}
} else {
fmt.Fprintf(&b, "%sAnalyze Disk%s %s%s%s", colorPurpleBold, colorReset, colorGray, displayPath(m.path), colorReset)
if !m.scanning {
fmt.Fprintf(&b, " | Total: %s", humanizeBytes(m.totalSize))
}
fmt.Fprintf(&b, "\n\n")
}
if m.deleting {
count := int64(0)
if m.deleteCount != nil {
count = atomic.LoadInt64(m.deleteCount)
}
fmt.Fprintf(&b, "%s%s%s%s Deleting: %s%s items%s removed, please wait...\n",
colorCyan, colorBold,
spinnerFrames[m.spinner],
colorReset,
colorYellow, formatNumber(count), colorReset)
return b.String()
}
if m.scanning {
filesScanned, dirsScanned, bytesScanned := m.getScanProgress()
progressPrefix := ""
if m.lastTotalFiles > 0 {
percent := float64(filesScanned) / float64(m.lastTotalFiles) * 100
// Cap at 100% generally
if percent > 100 {
percent = 100
}
// While strictly scanning, cap at 99% to avoid "100% but still working" confusion
if m.scanning && percent >= 100 {
percent = 99
}
progressPrefix = fmt.Sprintf(" %s(%.0f%%)%s", colorCyan, percent, colorReset)
}
fmt.Fprintf(&b, "%s%s%s%s Scanning%s: %s%s files%s, %s%s dirs%s, %s%s%s\n",
colorCyan, colorBold,
spinnerFrames[m.spinner],
colorReset,
progressPrefix,
colorYellow, formatNumber(filesScanned), colorReset,
colorYellow, formatNumber(dirsScanned), colorReset,
colorGreen, humanizeBytes(bytesScanned), colorReset)
if m.currentPath != nil {
currentPath := *m.currentPath
if currentPath != "" {
shortPath := displayPath(currentPath)
shortPath = truncateMiddle(shortPath, 50)
fmt.Fprintf(&b, "%s%s%s\n", colorGray, shortPath, colorReset)
}
}
return b.String()
}
if m.showLargeFiles {
if len(m.largeFiles) == 0 {
fmt.Fprintln(&b, " No large files found (>=100MB)")
} else {
viewport := calculateViewport(m.height, true)
start := max(m.largeOffset, 0)
end := min(start+viewport, len(m.largeFiles))
maxLargeSize := int64(1)
for _, file := range m.largeFiles {
if file.Size > maxLargeSize {
maxLargeSize = file.Size
}
}
nameWidth := calculateNameWidth(m.width)
for idx := start; idx < end; idx++ {
file := m.largeFiles[idx]
shortPath := displayPath(file.Path)
shortPath = truncateMiddle(shortPath, nameWidth)
paddedPath := padName(shortPath, nameWidth)
entryPrefix := " "
nameColor := ""
sizeColor := colorGray
numColor := ""
isMultiSelected := m.largeMultiSelected != nil && m.largeMultiSelected[file.Path]
selectIcon := "○"
if isMultiSelected {
selectIcon = fmt.Sprintf("%s●%s", colorGreen, colorReset)
nameColor = colorGreen
}
if idx == m.largeSelected {
entryPrefix = fmt.Sprintf(" %s%s▶%s ", colorCyan, colorBold, colorReset)
if !isMultiSelected {
nameColor = colorCyan
}
sizeColor = colorCyan
numColor = colorCyan
}
size := humanizeBytes(file.Size)
bar := coloredProgressBar(file.Size, maxLargeSize, 0)
fmt.Fprintf(&b, "%s%s %s%2d.%s %s | 📄 %s%s%s %s%10s%s\n",
entryPrefix, selectIcon, numColor, idx+1, colorReset, bar, nameColor, paddedPath, colorReset, sizeColor, size, colorReset)
}
}
} else {
if len(m.entries) == 0 {
fmt.Fprintln(&b, " Empty directory")
} else {
if m.inOverviewMode() {
maxSize := int64(1)
for _, entry := range m.entries {
if entry.Size > maxSize {
maxSize = entry.Size
}
}
totalSize := m.totalSize
// Overview paths are short; fixed width keeps layout stable.
nameWidth := 20
for idx, entry := range m.entries {
icon := "📁"
sizeVal := entry.Size
barValue := max(sizeVal, 0)
var percent float64
if totalSize > 0 && sizeVal >= 0 {
percent = float64(sizeVal) / float64(totalSize) * 100
} else {
percent = 0
}
percentStr := fmt.Sprintf("%5.1f%%", percent)
if totalSize == 0 || sizeVal < 0 {
percentStr = " -- "
}
bar := coloredProgressBar(barValue, maxSize, percent)
sizeText := "pending.."
if sizeVal >= 0 {
sizeText = humanizeBytes(sizeVal)
}
sizeColor := colorGray
if sizeVal >= 0 && totalSize > 0 {
switch {
case percent >= 50:
sizeColor = colorRed
case percent >= 20:
sizeColor = colorYellow
case percent >= 5:
sizeColor = colorBlue
default:
sizeColor = colorGray
}
}
entryPrefix := " "
name := trimNameWithWidth(entry.Name, nameWidth)
paddedName := padName(name, nameWidth)
nameSegment := fmt.Sprintf("%s %s", icon, paddedName)
numColor := ""
percentColor := ""
if idx == m.selected {
entryPrefix = fmt.Sprintf(" %s%s▶%s ", colorCyan, colorBold, colorReset)
nameSegment = fmt.Sprintf("%s%s %s%s", colorCyan, icon, paddedName, colorReset)
numColor = colorCyan
percentColor = colorCyan
sizeColor = colorCyan
}
displayIndex := idx + 1
var hintLabel string
if entry.IsDir && isCleanableDir(entry.Path) {
hintLabel = fmt.Sprintf("%s🧹%s", colorYellow, colorReset)
} else {
lastAccess := entry.LastAccess
if lastAccess.IsZero() && entry.Path != "" {
lastAccess = getLastAccessTime(entry.Path)
}
if unusedTime := formatUnusedTime(lastAccess); unusedTime != "" {
hintLabel = fmt.Sprintf("%s%s%s", colorGray, unusedTime, colorReset)
}
}
if hintLabel == "" {
fmt.Fprintf(&b, "%s%s%2d.%s %s %s%s%s | %s %s%10s%s\n",
entryPrefix, numColor, displayIndex, colorReset, bar, percentColor, percentStr, colorReset,
nameSegment, sizeColor, sizeText, colorReset)
} else {
fmt.Fprintf(&b, "%s%s%2d.%s %s %s%s%s | %s %s%10s%s %s\n",
entryPrefix, numColor, displayIndex, colorReset, bar, percentColor, percentStr, colorReset,
nameSegment, sizeColor, sizeText, colorReset, hintLabel)
}
}
} else {
maxSize := int64(1)
for _, entry := range m.entries {
if entry.Size > maxSize {
maxSize = entry.Size
}
}
viewport := calculateViewport(m.height, false)
nameWidth := calculateNameWidth(m.width)
start := max(m.offset, 0)
end := min(start+viewport, len(m.entries))
for idx := start; idx < end; idx++ {
entry := m.entries[idx]
icon := "📄"
if entry.IsDir {
icon = "📁"
}
size := humanizeBytes(entry.Size)
name := trimNameWithWidth(entry.Name, nameWidth)
paddedName := padName(name, nameWidth)
percent := float64(entry.Size) / float64(m.totalSize) * 100
percentStr := fmt.Sprintf("%5.1f%%", percent)
bar := coloredProgressBar(entry.Size, maxSize, percent)
var sizeColor string
if percent >= 50 {
sizeColor = colorRed
} else if percent >= 20 {
sizeColor = colorYellow
} else if percent >= 5 {
sizeColor = colorBlue
} else {
sizeColor = colorGray
}
isMultiSelected := m.multiSelected != nil && m.multiSelected[entry.Path]
selectIcon := "○"
nameColor := ""
if isMultiSelected {
selectIcon = fmt.Sprintf("%s●%s", colorGreen, colorReset)
nameColor = colorGreen
}
entryPrefix := " "
nameSegment := fmt.Sprintf("%s %s", icon, paddedName)
if nameColor != "" {
nameSegment = fmt.Sprintf("%s%s %s%s", nameColor, icon, paddedName, colorReset)
}
numColor := ""
percentColor := ""
if idx == m.selected {
entryPrefix = fmt.Sprintf(" %s%s▶%s ", colorCyan, colorBold, colorReset)
if !isMultiSelected {
nameSegment = fmt.Sprintf("%s%s %s%s", colorCyan, icon, paddedName, colorReset)
}
numColor = colorCyan
percentColor = colorCyan
sizeColor = colorCyan
}
displayIndex := idx + 1
var hintLabel string
if entry.IsDir && isCleanableDir(entry.Path) {
hintLabel = fmt.Sprintf("%s🧹%s", colorYellow, colorReset)
} else {
lastAccess := entry.LastAccess
if lastAccess.IsZero() && entry.Path != "" {
lastAccess = getLastAccessTime(entry.Path)
}
if unusedTime := formatUnusedTime(lastAccess); unusedTime != "" {
hintLabel = fmt.Sprintf("%s%s%s", colorGray, unusedTime, colorReset)
}
}
if hintLabel == "" {
fmt.Fprintf(&b, "%s%s %s%2d.%s %s %s%s%s | %s %s%10s%s\n",
entryPrefix, selectIcon, numColor, displayIndex, colorReset, bar, percentColor, percentStr, colorReset,
nameSegment, sizeColor, size, colorReset)
} else {
fmt.Fprintf(&b, "%s%s %s%2d.%s %s %s%s%s | %s %s%10s%s %s\n",
entryPrefix, selectIcon, numColor, displayIndex, colorReset, bar, percentColor, percentStr, colorReset,
nameSegment, sizeColor, size, colorReset, hintLabel)
}
}
}
}
}
fmt.Fprintln(&b)
if m.inOverviewMode() {
if len(m.history) > 0 {
fmt.Fprintf(&b, "%s↑↓←→ | Enter | R Refresh | O Open | F File | ← Back | Q Quit%s\n", colorGray, colorReset)
} else {
fmt.Fprintf(&b, "%s↑↓→ | Enter | R Refresh | O Open | F File | Q Quit%s\n", colorGray, colorReset)
}
} else if m.showLargeFiles {
selectCount := len(m.largeMultiSelected)
if selectCount > 0 {
fmt.Fprintf(&b, "%s↑↓← | Space Select | R Refresh | O Open | F File | ⌫ Del(%d) | ← Back | Q Quit%s\n", colorGray, selectCount, colorReset)
} else {
fmt.Fprintf(&b, "%s↑↓← | Space Select | R Refresh | O Open | F File | ⌫ Del | ← Back | Q Quit%s\n", colorGray, colorReset)
}
} else {
largeFileCount := len(m.largeFiles)
selectCount := len(m.multiSelected)
if selectCount > 0 {
if largeFileCount > 0 {
fmt.Fprintf(&b, "%s↑↓←→ | Space Select | Enter | R Refresh | O Open | F File | ⌫ Del(%d) | T Top(%d) | Q Quit%s\n", colorGray, selectCount, largeFileCount, colorReset)
} else {
fmt.Fprintf(&b, "%s↑↓←→ | Space Select | Enter | R Refresh | O Open | F File | ⌫ Del(%d) | Q Quit%s\n", colorGray, selectCount, colorReset)
}
} else {
if largeFileCount > 0 {
fmt.Fprintf(&b, "%s↑↓←→ | Space Select | Enter | R Refresh | O Open | F File | ⌫ Del | T Top(%d) | Q Quit%s\n", colorGray, largeFileCount, colorReset)
} else {
fmt.Fprintf(&b, "%s↑↓←→ | Space Select | Enter | R Refresh | O Open | F File | ⌫ Del | Q Quit%s\n", colorGray, colorReset)
}
}
}
if m.deleteConfirm && m.deleteTarget != nil {
fmt.Fprintln(&b)
var deleteCount int
var totalDeleteSize int64
if m.showLargeFiles && len(m.largeMultiSelected) > 0 {
deleteCount = len(m.largeMultiSelected)
for path := range m.largeMultiSelected {
for _, file := range m.largeFiles {
if file.Path == path {
totalDeleteSize += file.Size
break
}
}
}
} else if !m.showLargeFiles && len(m.multiSelected) > 0 {
deleteCount = len(m.multiSelected)
for path := range m.multiSelected {
for _, entry := range m.entries {
if entry.Path == path {
totalDeleteSize += entry.Size
break
}
}
}
}
if deleteCount > 1 {
fmt.Fprintf(&b, "%sDelete:%s %d items (%s) %sPress Enter to confirm | ESC cancel%s\n",
colorRed, colorReset,
deleteCount, humanizeBytes(totalDeleteSize),
colorGray, colorReset)
} else {
fmt.Fprintf(&b, "%sDelete:%s %s (%s) %sPress Enter to confirm | ESC cancel%s\n",
colorRed, colorReset,
m.deleteTarget.Name, humanizeBytes(m.deleteTarget.Size),
colorGray, colorReset)
}
}
return b.String()
}
// calculateViewport returns visible rows for the current terminal height.
func calculateViewport(termHeight int, isLargeFiles bool) int {
if termHeight <= 0 {
return defaultViewport
}
reserved := 6 // Header + footer
if isLargeFiles {
reserved = 5
}
available := termHeight - reserved
if available < 1 {
return 1
}
if available > 30 {
return 30
}
return available
}

View File

@@ -1,200 +1,674 @@
// Package main provides the mo status command for real-time system monitoring.
//go:build windows
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"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"
)
const refreshInterval = time.Second
// Styles
var (
Version = "dev"
BuildTime = ""
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)
)
type tickMsg struct{}
type animTickMsg struct{}
// Metrics snapshot
type MetricsSnapshot struct {
CollectedAt time.Time
HealthScore int
HealthMessage string
type metricsMsg struct {
data MetricsSnapshot
err error
// 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
width int
height int
metrics MetricsSnapshot
errMessage string
ready bool
lastUpdated time.Time
collecting bool
animFrame int
catHidden bool // true = hidden, false = visible
collector *Collector
metrics MetricsSnapshot
animFrame int
catHidden bool
ready bool
collecting bool
width int
height int
}
// getConfigPath returns the path to the status preferences file.
func getConfigPath() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".config", "mole", "status_prefs")
}
// loadCatHidden loads the cat hidden preference from config file.
func loadCatHidden() bool {
path := getConfigPath()
if path == "" {
return false
}
data, err := os.ReadFile(path)
if err != nil {
return false
}
return strings.TrimSpace(string(data)) == "cat_hidden=true"
}
// saveCatHidden saves the cat hidden preference to config file.
func saveCatHidden(hidden bool) {
path := getConfigPath()
if path == "" {
return
}
// Ensure directory exists
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return
}
value := "cat_hidden=false"
if hidden {
value = "cat_hidden=true"
}
_ = os.WriteFile(path, []byte(value+"\n"), 0644)
}
// Messages
type tickMsg time.Time
type metricsMsg MetricsSnapshot
func newModel() model {
return model{
collector: NewCollector(),
catHidden: loadCatHidden(),
animFrame: 0,
}
}
func (m model) Init() tea.Cmd {
return tea.Batch(tickAfter(0), animTick())
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", "esc", "ctrl+c":
case "q", "ctrl+c":
return m, tea.Quit
case "k":
// Toggle cat visibility and persist preference
case "c":
m.catHidden = !m.catHidden
saveCatHidden(m.catHidden)
return m, nil
case "r":
m.collecting = true
return m, m.collectMetrics()
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
return m, nil
case tickMsg:
if m.collecting {
return m, nil
}
m.collecting = true
return m, m.collectCmd()
case metricsMsg:
if msg.err != nil {
m.errMessage = msg.err.Error()
} else {
m.errMessage = ""
}
m.metrics = msg.data
m.lastUpdated = msg.data.CollectedAt
m.collecting = false
// Mark ready after first successful data collection.
if !m.ready {
m.ready = true
}
return m, tickAfter(refreshInterval)
case animTickMsg:
m.animFrame++
return m, animTickWithSpeed(m.metrics.CPU.Usage)
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 "Loading..."
return "\n Loading system metrics..."
}
header := renderHeader(m.metrics, m.errMessage, m.animFrame, m.width, m.catHidden)
cardWidth := 0
if m.width > 80 {
cardWidth = maxInt(24, m.width/2-4)
}
cards := buildCards(m.metrics, cardWidth)
var b strings.Builder
if m.width <= 80 {
var rendered []string
for i, c := range cards {
if i > 0 {
rendered = append(rendered, "")
// 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
}
rendered = append(rendered, renderCard(c, cardWidth, 0))
b.WriteString(fmt.Sprintf(" %s ↑%s ↓%s\n",
labelStyle.Render(truncateString(n.Name, 20)+":"),
valueStyle.Render(formatBytes(n.BytesSent)),
valueStyle.Render(formatBytes(n.BytesRecv)),
))
}
result := header + "\n" + lipgloss.JoinVertical(lipgloss.Left, rendered...)
// Add extra newline if cat is hidden for better spacing
if m.catHidden {
result = header + "\n\n" + lipgloss.JoinVertical(lipgloss.Left, rendered...)
}
return result
b.WriteString("\n")
}
twoCol := renderTwoColumns(cards, m.width)
// Add extra newline if cat is hidden for better spacing
if m.catHidden {
return header + "\n\n" + twoCol
// 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 ""
}
return header + "\n" + twoCol
}
func (m model) collectCmd() tea.Cmd {
return func() tea.Msg {
data, err := m.collector.Collect()
return metricsMsg{data: data, err: err}
frames := []string{
"🐹",
"🐹.",
"🐹..",
"🐹...",
}
return frames[frame%len(frames)]
}
func tickAfter(delay time.Duration) tea.Cmd {
return tea.Tick(delay, func(time.Time) tea.Msg { return tickMsg{} })
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 animTick() tea.Cmd {
return tea.Tick(200*time.Millisecond, func(time.Time) tea.Msg { return animTickMsg{} })
func getPercentColor(percent float64) lipgloss.Style {
if percent > 85 {
return dangerStyle
} else if percent > 70 {
return warnStyle
}
return okStyle
}
func animTickWithSpeed(cpuUsage float64) tea.Cmd {
// Higher CPU = faster animation.
interval := max(300-int(cpuUsage*2.5), 50)
return tea.Tick(time.Duration(interval)*time.Millisecond, func(time.Time) tea.Msg { return animTickMsg{} })
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, "system status error: %v\n", err)
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}

219
cmd/status/main_test.go Normal file
View File

@@ -0,0 +1,219 @@
//go:build windows
package main
import (
"testing"
"time"
)
func TestFormatBytesUint64(t *testing.T) {
tests := []struct {
input uint64
expected string
}{
{0, "0 B"},
{512, "512 B"},
{1024, "1.0 KB"},
{1536, "1.5 KB"},
{1048576, "1.0 MB"},
{1073741824, "1.0 GB"},
{1099511627776, "1.0 TB"},
}
for _, test := range tests {
result := formatBytes(test.input)
if result != test.expected {
t.Errorf("formatBytes(%d) = %s, expected %s", test.input, result, test.expected)
}
}
}
func TestFormatDuration(t *testing.T) {
tests := []struct {
input time.Duration
expected string
}{
{5 * time.Minute, "5m"},
{2 * time.Hour, "2h 0m"},
{25 * time.Hour, "1d 1h 0m"},
{49*time.Hour + 30*time.Minute, "2d 1h 30m"},
}
for _, test := range tests {
result := formatDuration(test.input)
if result != test.expected {
t.Errorf("formatDuration(%v) = %s, expected %s", test.input, result, test.expected)
}
}
}
func TestTruncateString(t *testing.T) {
tests := []struct {
input string
maxLen int
expected string
}{
{"short", 10, "short"},
{"this is a long string", 10, "this is..."},
{"exact", 5, "exact"},
}
for _, test := range tests {
result := truncateString(test.input, test.maxLen)
if result != test.expected {
t.Errorf("truncateString(%s, %d) = %s, expected %s", test.input, test.maxLen, result, test.expected)
}
}
}
func TestCalculateHealthScore(t *testing.T) {
tests := []struct {
name string
snapshot MetricsSnapshot
minScore int
maxScore int
}{
{
name: "Healthy system",
snapshot: MetricsSnapshot{
CPUPercent: 20,
MemPercent: 40,
SwapPercent: 10,
Disks: []DiskInfo{
{UsedPercent: 50},
},
},
minScore: 90,
maxScore: 100,
},
{
name: "High CPU",
snapshot: MetricsSnapshot{
CPUPercent: 95,
MemPercent: 40,
SwapPercent: 10,
Disks: []DiskInfo{
{UsedPercent: 50},
},
},
minScore: 50,
maxScore: 75,
},
{
name: "High Memory",
snapshot: MetricsSnapshot{
CPUPercent: 20,
MemPercent: 95,
SwapPercent: 10,
Disks: []DiskInfo{
{UsedPercent: 50},
},
},
minScore: 60,
maxScore: 80,
},
{
name: "Critical Disk",
snapshot: MetricsSnapshot{
CPUPercent: 20,
MemPercent: 40,
SwapPercent: 10,
Disks: []DiskInfo{
{Device: "C:", UsedPercent: 98},
},
},
minScore: 60,
maxScore: 85,
},
{
name: "Multiple issues",
snapshot: MetricsSnapshot{
CPUPercent: 95,
MemPercent: 95,
SwapPercent: 85,
Disks: []DiskInfo{
{Device: "C:", UsedPercent: 98},
},
},
minScore: 0,
maxScore: 30,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
score, msg := calculateHealthScore(test.snapshot)
if score < test.minScore || score > test.maxScore {
t.Errorf("calculateHealthScore() = %d (%s), expected between %d and %d",
score, msg, test.minScore, test.maxScore)
}
})
}
}
func TestNewCollector(t *testing.T) {
collector := NewCollector()
if collector == nil {
t.Fatal("NewCollector returned nil")
}
if collector.prevNet == nil {
t.Error("prevNet map should be initialized")
}
}
func TestGetMoleFrame(t *testing.T) {
// Test visible frames
for i := 0; i < 8; i++ {
frame := getMoleFrame(i, false)
if frame == "" {
t.Errorf("getMoleFrame(%d, false) returned empty string", i)
}
}
// Test hidden
frame := getMoleFrame(0, true)
if frame != "" {
t.Errorf("getMoleFrame(0, true) = %s, expected empty string", frame)
}
}
func TestRenderProgressBar(t *testing.T) {
tests := []struct {
percent float64
width int
}{
{0, 20},
{50, 20},
{100, 20},
{75, 30},
}
for _, test := range tests {
result := renderProgressBar(test.percent, test.width)
if result == "" {
t.Errorf("renderProgressBar(%.0f, %d) returned empty string", test.percent, test.width)
}
}
}
func TestGetPercentColor(t *testing.T) {
// Just verify it doesn't panic
_ = getPercentColor(50)
_ = getPercentColor(75)
_ = getPercentColor(90)
}
func TestNewModel(t *testing.T) {
model := newModel()
if model.collector == nil {
t.Error("collector should be initialized")
}
if model.ready {
t.Error("ready should be false initially")
}
}

View File

@@ -1,294 +0,0 @@
package main
import (
"context"
"fmt"
"os/exec"
"sync"
"time"
"github.com/shirou/gopsutil/v3/disk"
"github.com/shirou/gopsutil/v3/host"
"github.com/shirou/gopsutil/v3/net"
)
type MetricsSnapshot struct {
CollectedAt time.Time
Host string
Platform string
Uptime string
Procs uint64
Hardware HardwareInfo
HealthScore int // 0-100 system health score
HealthScoreMsg string // Brief explanation
CPU CPUStatus
GPU []GPUStatus
Memory MemoryStatus
Disks []DiskStatus
DiskIO DiskIOStatus
Network []NetworkStatus
Proxy ProxyStatus
Batteries []BatteryStatus
Thermal ThermalStatus
Sensors []SensorReading
Bluetooth []BluetoothDevice
TopProcesses []ProcessInfo
}
type HardwareInfo struct {
Model string // MacBook Pro 14-inch, 2021
CPUModel string // Apple M1 Pro / Intel Core i7
TotalRAM string // 16GB
DiskSize string // 512GB
OSVersion string // macOS Sonoma 14.5
RefreshRate string // 120Hz / 60Hz
}
type DiskIOStatus struct {
ReadRate float64 // MB/s
WriteRate float64 // MB/s
}
type ProcessInfo struct {
Name string
CPU float64
Memory float64
}
type CPUStatus struct {
Usage float64
PerCore []float64
PerCoreEstimated bool
Load1 float64
Load5 float64
Load15 float64
CoreCount int
LogicalCPU int
PCoreCount int // Performance cores (Apple Silicon)
ECoreCount int // Efficiency cores (Apple Silicon)
}
type GPUStatus struct {
Name string
Usage float64
MemoryUsed float64
MemoryTotal float64
CoreCount int
Note string
}
type MemoryStatus struct {
Used uint64
Total uint64
UsedPercent float64
SwapUsed uint64
SwapTotal uint64
Cached uint64 // File cache that can be freed if needed
Pressure string // macOS memory pressure: normal/warn/critical
}
type DiskStatus struct {
Mount string
Device string
Used uint64
Total uint64
UsedPercent float64
Fstype string
External bool
}
type NetworkStatus struct {
Name string
RxRateMBs float64
TxRateMBs float64
IP string
}
type ProxyStatus struct {
Enabled bool
Type string // HTTP, SOCKS, System
Host string
}
type BatteryStatus struct {
Percent float64
Status string
TimeLeft string
Health string
CycleCount int
Capacity int // Maximum capacity percentage (e.g., 85 means 85% of original)
}
type ThermalStatus struct {
CPUTemp float64
GPUTemp float64
FanSpeed int
FanCount int
SystemPower float64 // System power consumption in Watts
AdapterPower float64 // AC adapter max power in Watts
BatteryPower float64 // Battery charge/discharge power in Watts (positive = discharging)
}
type SensorReading struct {
Label string
Value float64
Unit string
Note string
}
type BluetoothDevice struct {
Name string
Connected bool
Battery string
}
type Collector struct {
// Static cache.
cachedHW HardwareInfo
lastHWAt time.Time
hasStatic bool
// Slow cache (30s-1m).
lastBTAt time.Time
lastBT []BluetoothDevice
// Fast metrics (1s).
prevNet map[string]net.IOCountersStat
lastNetAt time.Time
lastGPUAt time.Time
cachedGPU []GPUStatus
prevDiskIO disk.IOCountersStat
lastDiskAt time.Time
}
func NewCollector() *Collector {
return &Collector{
prevNet: make(map[string]net.IOCountersStat),
}
}
func (c *Collector) Collect() (MetricsSnapshot, error) {
now := time.Now()
// Host info is cached by gopsutil; fetch once.
hostInfo, _ := host.Info()
var (
wg sync.WaitGroup
errMu sync.Mutex
mergeErr error
cpuStats CPUStatus
memStats MemoryStatus
diskStats []DiskStatus
diskIO DiskIOStatus
netStats []NetworkStatus
proxyStats ProxyStatus
batteryStats []BatteryStatus
thermalStats ThermalStatus
sensorStats []SensorReading
gpuStats []GPUStatus
btStats []BluetoothDevice
topProcs []ProcessInfo
)
// Helper to launch concurrent collection.
collect := func(fn func() error) {
wg.Add(1)
go func() {
defer wg.Done()
if err := fn(); err != nil {
errMu.Lock()
if mergeErr == nil {
mergeErr = err
} else {
mergeErr = fmt.Errorf("%v; %w", mergeErr, err)
}
errMu.Unlock()
}
}()
}
// Launch independent collection tasks.
collect(func() (err error) { cpuStats, err = collectCPU(); return })
collect(func() (err error) { memStats, err = collectMemory(); return })
collect(func() (err error) { diskStats, err = collectDisks(); return })
collect(func() (err error) { diskIO = c.collectDiskIO(now); return nil })
collect(func() (err error) { netStats, err = c.collectNetwork(now); return })
collect(func() (err error) { proxyStats = collectProxy(); return nil })
collect(func() (err error) { batteryStats, _ = collectBatteries(); return nil })
collect(func() (err error) { thermalStats = collectThermal(); return nil })
collect(func() (err error) { sensorStats, _ = collectSensors(); return nil })
collect(func() (err error) { gpuStats, err = c.collectGPU(now); return })
collect(func() (err error) {
// Bluetooth is slow; cache for 30s.
if now.Sub(c.lastBTAt) > 30*time.Second || len(c.lastBT) == 0 {
btStats = c.collectBluetooth(now)
c.lastBT = btStats
c.lastBTAt = now
} else {
btStats = c.lastBT
}
return nil
})
collect(func() (err error) { topProcs = collectTopProcesses(); return nil })
// Wait for all to complete.
wg.Wait()
// Dependent tasks (post-collect).
// Cache hardware info as it's expensive and rarely changes.
if !c.hasStatic || now.Sub(c.lastHWAt) > 10*time.Minute {
c.cachedHW = collectHardware(memStats.Total, diskStats)
c.lastHWAt = now
c.hasStatic = true
}
hwInfo := c.cachedHW
score, scoreMsg := calculateHealthScore(cpuStats, memStats, diskStats, diskIO, thermalStats)
return MetricsSnapshot{
CollectedAt: now,
Host: hostInfo.Hostname,
Platform: fmt.Sprintf("%s %s", hostInfo.Platform, hostInfo.PlatformVersion),
Uptime: formatUptime(hostInfo.Uptime),
Procs: hostInfo.Procs,
Hardware: hwInfo,
HealthScore: score,
HealthScoreMsg: scoreMsg,
CPU: cpuStats,
GPU: gpuStats,
Memory: memStats,
Disks: diskStats,
DiskIO: diskIO,
Network: netStats,
Proxy: proxyStats,
Batteries: batteryStats,
Thermal: thermalStats,
Sensors: sensorStats,
Bluetooth: btStats,
TopProcesses: topProcs,
}, mergeErr
}
func runCmd(ctx context.Context, name string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, name, args...)
output, err := cmd.Output()
if err != nil {
return "", err
}
return string(output), nil
}
func commandExists(name string) bool {
if name == "" {
return false
}
defer func() {
// Treat LookPath panics as "missing".
_ = recover()
}()
_, err := exec.LookPath(name)
return err == nil
}

View File

@@ -1,289 +0,0 @@
package main
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
"github.com/shirou/gopsutil/v3/host"
)
var (
// Cache for heavy system_profiler output.
lastPowerAt time.Time
cachedPower string
powerCacheTTL = 30 * time.Second
)
func collectBatteries() (batts []BatteryStatus, err error) {
defer func() {
if r := recover(); r != nil {
// Swallow panics to keep UI alive.
err = fmt.Errorf("battery collection failed: %v", r)
}
}()
// macOS: pmset for real-time percentage/status.
if runtime.GOOS == "darwin" && commandExists("pmset") {
if out, err := runCmd(context.Background(), "pmset", "-g", "batt"); err == nil {
// Health/cycles/capacity from cached system_profiler.
health, cycles, capacity := getCachedPowerData()
if batts := parsePMSet(out, health, cycles, capacity); len(batts) > 0 {
return batts, nil
}
}
}
// Linux: /sys/class/power_supply.
matches, _ := filepath.Glob("/sys/class/power_supply/BAT*/capacity")
for _, capFile := range matches {
statusFile := filepath.Join(filepath.Dir(capFile), "status")
capData, err := os.ReadFile(capFile)
if err != nil {
continue
}
statusData, _ := os.ReadFile(statusFile)
percentStr := strings.TrimSpace(string(capData))
percent, _ := strconv.ParseFloat(percentStr, 64)
status := strings.TrimSpace(string(statusData))
if status == "" {
status = "Unknown"
}
batts = append(batts, BatteryStatus{
Percent: percent,
Status: status,
})
}
if len(batts) > 0 {
return batts, nil
}
return nil, errors.New("no battery data found")
}
func parsePMSet(raw string, health string, cycles int, capacity int) []BatteryStatus {
var out []BatteryStatus
var timeLeft string
for line := range strings.Lines(raw) {
// Time remaining.
if strings.Contains(line, "remaining") {
parts := strings.Fields(line)
for i, p := range parts {
if p == "remaining" && i > 0 {
timeLeft = parts[i-1]
}
}
}
if !strings.Contains(line, "%") {
continue
}
fields := strings.Fields(line)
var (
percent float64
found bool
status = "Unknown"
)
for i, f := range fields {
if strings.Contains(f, "%") {
value := strings.TrimSuffix(strings.TrimSuffix(f, ";"), "%")
if p, err := strconv.ParseFloat(value, 64); err == nil {
percent = p
found = true
if i+1 < len(fields) {
status = strings.TrimSuffix(fields[i+1], ";")
}
}
break
}
}
if !found {
continue
}
out = append(out, BatteryStatus{
Percent: percent,
Status: status,
TimeLeft: timeLeft,
Health: health,
CycleCount: cycles,
Capacity: capacity,
})
}
return out
}
// getCachedPowerData returns condition, cycles, and capacity from cached system_profiler.
func getCachedPowerData() (health string, cycles int, capacity int) {
out := getSystemPowerOutput()
if out == "" {
return "", 0, 0
}
for line := range strings.Lines(out) {
lower := strings.ToLower(line)
if strings.Contains(lower, "cycle count") {
if _, after, found := strings.Cut(line, ":"); found {
cycles, _ = strconv.Atoi(strings.TrimSpace(after))
}
}
if strings.Contains(lower, "condition") {
if _, after, found := strings.Cut(line, ":"); found {
health = strings.TrimSpace(after)
}
}
if strings.Contains(lower, "maximum capacity") {
if _, after, found := strings.Cut(line, ":"); found {
capacityStr := strings.TrimSpace(after)
capacityStr = strings.TrimSuffix(capacityStr, "%")
capacity, _ = strconv.Atoi(strings.TrimSpace(capacityStr))
}
}
}
return health, cycles, capacity
}
func getSystemPowerOutput() string {
if runtime.GOOS != "darwin" {
return ""
}
now := time.Now()
if cachedPower != "" && now.Sub(lastPowerAt) < powerCacheTTL {
return cachedPower
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
out, err := runCmd(ctx, "system_profiler", "SPPowerDataType")
if err == nil {
cachedPower = out
lastPowerAt = now
}
return cachedPower
}
func collectThermal() ThermalStatus {
if runtime.GOOS != "darwin" {
return ThermalStatus{}
}
var thermal ThermalStatus
// Fan info from cached system_profiler.
out := getSystemPowerOutput()
if out != "" {
for line := range strings.Lines(out) {
lower := strings.ToLower(line)
if strings.Contains(lower, "fan") && strings.Contains(lower, "speed") {
if _, after, found := strings.Cut(line, ":"); found {
numStr := strings.TrimSpace(after)
numStr, _, _ = strings.Cut(numStr, " ")
thermal.FanSpeed, _ = strconv.Atoi(numStr)
}
}
}
}
// Power metrics from ioreg (fast, real-time).
ctxPower, cancelPower := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancelPower()
if out, err := runCmd(ctxPower, "ioreg", "-rn", "AppleSmartBattery"); err == nil {
for line := range strings.Lines(out) {
line = strings.TrimSpace(line)
// Battery temperature ("Temperature" = 3055).
if _, after, found := strings.Cut(line, "\"Temperature\" = "); found {
valStr := strings.TrimSpace(after)
if tempRaw, err := strconv.Atoi(valStr); err == nil && tempRaw > 0 {
thermal.CPUTemp = float64(tempRaw) / 100.0
}
}
// Adapter power (Watts) from current adapter.
if strings.Contains(line, "\"AdapterDetails\" = {") && !strings.Contains(line, "AppleRaw") {
if _, after, found := strings.Cut(line, "\"Watts\"="); found {
valStr := strings.TrimSpace(after)
valStr, _, _ = strings.Cut(valStr, ",")
valStr, _, _ = strings.Cut(valStr, "}")
valStr = strings.TrimSpace(valStr)
if watts, err := strconv.ParseFloat(valStr, 64); err == nil && watts > 0 {
thermal.AdapterPower = watts
}
}
}
// System power consumption (mW -> W).
if _, after, found := strings.Cut(line, "\"SystemPowerIn\"="); found {
valStr := strings.TrimSpace(after)
valStr, _, _ = strings.Cut(valStr, ",")
valStr, _, _ = strings.Cut(valStr, "}")
valStr = strings.TrimSpace(valStr)
if powerMW, err := strconv.ParseFloat(valStr, 64); err == nil && powerMW > 0 {
thermal.SystemPower = powerMW / 1000.0
}
}
// Battery power (mW -> W, positive = discharging).
if _, after, found := strings.Cut(line, "\"BatteryPower\"="); found {
valStr := strings.TrimSpace(after)
valStr, _, _ = strings.Cut(valStr, ",")
valStr, _, _ = strings.Cut(valStr, "}")
valStr = strings.TrimSpace(valStr)
// Parse as int64 first to handle negative values (charging)
if powerMW, err := strconv.ParseInt(valStr, 10, 64); err == nil {
thermal.BatteryPower = float64(powerMW) / 1000.0
}
}
}
}
// Fallback: thermal level proxy.
if thermal.CPUTemp == 0 {
ctx2, cancel2 := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel2()
out2, err := runCmd(ctx2, "sysctl", "-n", "machdep.xcpm.cpu_thermal_level")
if err == nil {
level, _ := strconv.Atoi(strings.TrimSpace(out2))
if level >= 0 {
thermal.CPUTemp = 45 + float64(level)*0.5
}
}
}
return thermal
}
func collectSensors() ([]SensorReading, error) {
temps, err := host.SensorsTemperatures()
if err != nil {
return nil, err
}
var out []SensorReading
for _, t := range temps {
if t.Temperature <= 0 || t.Temperature > 150 {
continue
}
out = append(out, SensorReading{
Label: prettifyLabel(t.SensorKey),
Value: t.Temperature,
Unit: "°C",
})
}
return out, nil
}
func prettifyLabel(key string) string {
key = strings.TrimSpace(key)
key = strings.TrimPrefix(key, "TC")
key = strings.ReplaceAll(key, "_", " ")
return key
}

View File

@@ -1,138 +0,0 @@
package main
import (
"context"
"errors"
"runtime"
"strings"
"time"
)
const (
bluetoothCacheTTL = 30 * time.Second
bluetoothctlTimeout = 1500 * time.Millisecond
)
func (c *Collector) collectBluetooth(now time.Time) []BluetoothDevice {
if len(c.lastBT) > 0 && !c.lastBTAt.IsZero() && now.Sub(c.lastBTAt) < bluetoothCacheTTL {
return c.lastBT
}
if devs, err := readSystemProfilerBluetooth(); err == nil && len(devs) > 0 {
c.lastBTAt = now
c.lastBT = devs
return devs
}
if devs, err := readBluetoothCTLDevices(); err == nil && len(devs) > 0 {
c.lastBTAt = now
c.lastBT = devs
return devs
}
c.lastBTAt = now
if len(c.lastBT) == 0 {
c.lastBT = []BluetoothDevice{{Name: "No Bluetooth info", Connected: false}}
}
return c.lastBT
}
func readSystemProfilerBluetooth() ([]BluetoothDevice, error) {
if runtime.GOOS != "darwin" || !commandExists("system_profiler") {
return nil, errors.New("system_profiler unavailable")
}
ctx, cancel := context.WithTimeout(context.Background(), systemProfilerTimeout)
defer cancel()
out, err := runCmd(ctx, "system_profiler", "SPBluetoothDataType")
if err != nil {
return nil, err
}
return parseSPBluetooth(out), nil
}
func readBluetoothCTLDevices() ([]BluetoothDevice, error) {
if !commandExists("bluetoothctl") {
return nil, errors.New("bluetoothctl unavailable")
}
ctx, cancel := context.WithTimeout(context.Background(), bluetoothctlTimeout)
defer cancel()
out, err := runCmd(ctx, "bluetoothctl", "info")
if err != nil {
return nil, err
}
return parseBluetoothctl(out), nil
}
func parseSPBluetooth(raw string) []BluetoothDevice {
var devices []BluetoothDevice
var currentName string
var connected bool
var battery string
for line := range strings.Lines(raw) {
trim := strings.TrimSpace(line)
if len(trim) == 0 {
continue
}
if !strings.HasPrefix(line, " ") && strings.HasSuffix(trim, ":") {
// Reset at top-level sections.
currentName = ""
connected = false
battery = ""
continue
}
if strings.HasPrefix(line, " ") && strings.HasSuffix(trim, ":") {
if currentName != "" {
devices = append(devices, BluetoothDevice{Name: currentName, Connected: connected, Battery: battery})
}
currentName = strings.TrimSuffix(trim, ":")
connected = false
battery = ""
continue
}
if strings.Contains(trim, "Connected:") {
connected = strings.Contains(trim, "Yes")
}
if strings.Contains(trim, "Battery Level:") {
battery = strings.TrimSpace(strings.TrimPrefix(trim, "Battery Level:"))
}
}
if currentName != "" {
devices = append(devices, BluetoothDevice{Name: currentName, Connected: connected, Battery: battery})
}
if len(devices) == 0 {
return []BluetoothDevice{{Name: "No devices", Connected: false}}
}
return devices
}
func parseBluetoothctl(raw string) []BluetoothDevice {
var devices []BluetoothDevice
current := BluetoothDevice{}
for line := range strings.Lines(raw) {
trim := strings.TrimSpace(line)
if strings.HasPrefix(trim, "Device ") {
if current.Name != "" {
devices = append(devices, current)
}
current = BluetoothDevice{Name: strings.TrimPrefix(trim, "Device "), Connected: false}
}
if after, ok := strings.CutPrefix(trim, "Name:"); ok {
current.Name = strings.TrimSpace(after)
}
if strings.HasPrefix(trim, "Connected:") {
current.Connected = strings.Contains(trim, "yes")
}
}
if current.Name != "" {
devices = append(devices, current)
}
if len(devices) == 0 {
return []BluetoothDevice{{Name: "No devices", Connected: false}}
}
return devices
}

View File

@@ -1,261 +0,0 @@
package main
import (
"bufio"
"context"
"errors"
"runtime"
"strconv"
"strings"
"time"
"github.com/shirou/gopsutil/v3/cpu"
"github.com/shirou/gopsutil/v3/load"
)
const (
cpuSampleInterval = 200 * time.Millisecond
)
func collectCPU() (CPUStatus, error) {
counts, countsErr := cpu.Counts(false)
if countsErr != nil || counts == 0 {
counts = runtime.NumCPU()
}
logical, logicalErr := cpu.Counts(true)
if logicalErr != nil || logical == 0 {
logical = runtime.NumCPU()
}
if logical <= 0 {
logical = 1
}
// Two-call pattern for more reliable CPU usage.
warmUpCPU()
time.Sleep(cpuSampleInterval)
percents, err := cpu.Percent(0, true)
var totalPercent float64
perCoreEstimated := false
if err != nil || len(percents) == 0 {
fallbackUsage, fallbackPerCore, fallbackErr := fallbackCPUUtilization(logical)
if fallbackErr != nil {
if err != nil {
return CPUStatus{}, err
}
return CPUStatus{}, fallbackErr
}
totalPercent = fallbackUsage
percents = fallbackPerCore
perCoreEstimated = true
} else {
for _, v := range percents {
totalPercent += v
}
totalPercent /= float64(len(percents))
}
loadStats, loadErr := load.Avg()
var loadAvg load.AvgStat
if loadStats != nil {
loadAvg = *loadStats
}
if loadErr != nil || isZeroLoad(loadAvg) {
if fallback, err := fallbackLoadAvgFromUptime(); err == nil {
loadAvg = fallback
}
}
// P/E core counts for Apple Silicon.
pCores, eCores := getCoreTopology()
return CPUStatus{
Usage: totalPercent,
PerCore: percents,
PerCoreEstimated: perCoreEstimated,
Load1: loadAvg.Load1,
Load5: loadAvg.Load5,
Load15: loadAvg.Load15,
CoreCount: counts,
LogicalCPU: logical,
PCoreCount: pCores,
ECoreCount: eCores,
}, nil
}
func isZeroLoad(avg load.AvgStat) bool {
return avg.Load1 == 0 && avg.Load5 == 0 && avg.Load15 == 0
}
var (
// Cache for core topology.
lastTopologyAt time.Time
cachedP, cachedE int
topologyTTL = 10 * time.Minute
)
// getCoreTopology returns P/E core counts on Apple Silicon.
func getCoreTopology() (pCores, eCores int) {
if runtime.GOOS != "darwin" {
return 0, 0
}
now := time.Now()
if cachedP > 0 || cachedE > 0 {
if now.Sub(lastTopologyAt) < topologyTTL {
return cachedP, cachedE
}
}
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
out, err := runCmd(ctx, "sysctl", "-n",
"hw.perflevel0.logicalcpu",
"hw.perflevel0.name",
"hw.perflevel1.logicalcpu",
"hw.perflevel1.name")
if err != nil {
return 0, 0
}
var lines []string
for line := range strings.Lines(strings.TrimSpace(out)) {
lines = append(lines, line)
}
if len(lines) < 4 {
return 0, 0
}
level0Count, _ := strconv.Atoi(strings.TrimSpace(lines[0]))
level0Name := strings.ToLower(strings.TrimSpace(lines[1]))
level1Count, _ := strconv.Atoi(strings.TrimSpace(lines[2]))
level1Name := strings.ToLower(strings.TrimSpace(lines[3]))
if strings.Contains(level0Name, "performance") {
pCores = level0Count
} else if strings.Contains(level0Name, "efficiency") {
eCores = level0Count
}
if strings.Contains(level1Name, "performance") {
pCores = level1Count
} else if strings.Contains(level1Name, "efficiency") {
eCores = level1Count
}
cachedP, cachedE = pCores, eCores
lastTopologyAt = now
return pCores, eCores
}
func fallbackLoadAvgFromUptime() (load.AvgStat, error) {
if !commandExists("uptime") {
return load.AvgStat{}, errors.New("uptime command unavailable")
}
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
out, err := runCmd(ctx, "uptime")
if err != nil {
return load.AvgStat{}, err
}
markers := []string{"load averages:", "load average:"}
idx := -1
for _, marker := range markers {
if pos := strings.LastIndex(out, marker); pos != -1 {
idx = pos + len(marker)
break
}
}
if idx == -1 {
return load.AvgStat{}, errors.New("load averages not found in uptime output")
}
segment := strings.TrimSpace(out[idx:])
fields := strings.Fields(segment)
var values []float64
for _, field := range fields {
field = strings.Trim(field, ",;")
if field == "" {
continue
}
val, err := strconv.ParseFloat(field, 64)
if err != nil {
continue
}
values = append(values, val)
if len(values) == 3 {
break
}
}
if len(values) < 3 {
return load.AvgStat{}, errors.New("could not parse load averages from uptime output")
}
return load.AvgStat{
Load1: values[0],
Load5: values[1],
Load15: values[2],
}, nil
}
func fallbackCPUUtilization(logical int) (float64, []float64, error) {
if logical <= 0 {
logical = runtime.NumCPU()
}
if logical <= 0 {
logical = 1
}
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
out, err := runCmd(ctx, "ps", "-Aceo", "pcpu")
if err != nil {
return 0, nil, err
}
scanner := bufio.NewScanner(strings.NewReader(out))
total := 0.0
lineIndex := 0
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
lineIndex++
if lineIndex == 1 && (strings.Contains(strings.ToLower(line), "cpu") || strings.Contains(line, "%")) {
continue
}
val, parseErr := strconv.ParseFloat(line, 64)
if parseErr != nil {
continue
}
total += val
}
if scanErr := scanner.Err(); scanErr != nil {
return 0, nil, scanErr
}
maxTotal := float64(logical * 100)
if total < 0 {
total = 0
} else if total > maxTotal {
total = maxTotal
}
avg := total / float64(logical)
perCore := make([]float64, logical)
for i := range perCore {
perCore[i] = avg
}
return avg, perCore, nil
}
func warmUpCPU() {
cpu.Percent(0, true) //nolint:errcheck
}

View File

@@ -1,214 +0,0 @@
package main
import (
"context"
"errors"
"fmt"
"runtime"
"sort"
"strings"
"time"
"github.com/shirou/gopsutil/v3/disk"
)
var skipDiskMounts = map[string]bool{
"/System/Volumes/VM": true,
"/System/Volumes/Preboot": true,
"/System/Volumes/Update": true,
"/System/Volumes/xarts": true,
"/System/Volumes/Hardware": true,
"/System/Volumes/Data": true,
"/dev": true,
}
func collectDisks() ([]DiskStatus, error) {
partitions, err := disk.Partitions(false)
if err != nil {
return nil, err
}
var (
disks []DiskStatus
seenDevice = make(map[string]bool)
seenVolume = make(map[string]bool)
)
for _, part := range partitions {
if strings.HasPrefix(part.Device, "/dev/loop") {
continue
}
if skipDiskMounts[part.Mountpoint] {
continue
}
if strings.HasPrefix(part.Mountpoint, "/System/Volumes/") {
continue
}
// Skip /private mounts.
if strings.HasPrefix(part.Mountpoint, "/private/") {
continue
}
baseDevice := baseDeviceName(part.Device)
if baseDevice == "" {
baseDevice = part.Device
}
if seenDevice[baseDevice] {
continue
}
usage, err := disk.Usage(part.Mountpoint)
if err != nil || usage.Total == 0 {
continue
}
// Skip <1GB volumes.
if usage.Total < 1<<30 {
continue
}
// Use size-based dedupe key for shared pools.
volKey := fmt.Sprintf("%s:%d", part.Fstype, usage.Total)
if seenVolume[volKey] {
continue
}
disks = append(disks, DiskStatus{
Mount: part.Mountpoint,
Device: part.Device,
Used: usage.Used,
Total: usage.Total,
UsedPercent: usage.UsedPercent,
Fstype: part.Fstype,
})
seenDevice[baseDevice] = true
seenVolume[volKey] = true
}
annotateDiskTypes(disks)
sort.Slice(disks, func(i, j int) bool {
return disks[i].Total > disks[j].Total
})
if len(disks) > 3 {
disks = disks[:3]
}
return disks, nil
}
var (
// External disk cache.
lastDiskCacheAt time.Time
diskTypeCache = make(map[string]bool)
diskCacheTTL = 2 * time.Minute
)
func annotateDiskTypes(disks []DiskStatus) {
if len(disks) == 0 || runtime.GOOS != "darwin" || !commandExists("diskutil") {
return
}
now := time.Now()
// Clear stale cache.
if now.Sub(lastDiskCacheAt) > diskCacheTTL {
diskTypeCache = make(map[string]bool)
lastDiskCacheAt = now
}
for i := range disks {
base := baseDeviceName(disks[i].Device)
if base == "" {
base = disks[i].Device
}
if val, ok := diskTypeCache[base]; ok {
disks[i].External = val
continue
}
external, err := isExternalDisk(base)
if err != nil {
external = strings.HasPrefix(disks[i].Mount, "/Volumes/")
}
disks[i].External = external
diskTypeCache[base] = external
}
}
func baseDeviceName(device string) string {
device = strings.TrimPrefix(device, "/dev/")
if !strings.HasPrefix(device, "disk") {
return device
}
for i := 4; i < len(device); i++ {
if device[i] == 's' {
return device[:i]
}
}
return device
}
func isExternalDisk(device string) (bool, error) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
out, err := runCmd(ctx, "diskutil", "info", device)
if err != nil {
return false, err
}
var (
found bool
external bool
)
for line := range strings.Lines(out) {
trim := strings.TrimSpace(line)
if strings.HasPrefix(trim, "Internal:") {
found = true
external = strings.Contains(trim, "No")
break
}
if strings.HasPrefix(trim, "Device Location:") {
found = true
external = strings.Contains(trim, "External")
}
}
if !found {
return false, errors.New("diskutil info missing Internal field")
}
return external, nil
}
func (c *Collector) collectDiskIO(now time.Time) DiskIOStatus {
counters, err := disk.IOCounters()
if err != nil || len(counters) == 0 {
return DiskIOStatus{}
}
var total disk.IOCountersStat
for _, v := range counters {
total.ReadBytes += v.ReadBytes
total.WriteBytes += v.WriteBytes
}
if c.lastDiskAt.IsZero() {
c.prevDiskIO = total
c.lastDiskAt = now
return DiskIOStatus{}
}
elapsed := now.Sub(c.lastDiskAt).Seconds()
if elapsed <= 0 {
elapsed = 1
}
readRate := float64(total.ReadBytes-c.prevDiskIO.ReadBytes) / 1024 / 1024 / elapsed
writeRate := float64(total.WriteBytes-c.prevDiskIO.WriteBytes) / 1024 / 1024 / elapsed
c.prevDiskIO = total
c.lastDiskAt = now
if readRate < 0 {
readRate = 0
}
if writeRate < 0 {
writeRate = 0
}
return DiskIOStatus{ReadRate: readRate, WriteRate: writeRate}
}

View File

@@ -1,184 +0,0 @@
package main
import (
"context"
"encoding/json"
"errors"
"regexp"
"runtime"
"strconv"
"strings"
"time"
)
const (
systemProfilerTimeout = 4 * time.Second
macGPUInfoTTL = 10 * time.Minute
powermetricsTimeout = 2 * time.Second
)
// Regex for GPU usage parsing.
var (
gpuActiveResidencyRe = regexp.MustCompile(`GPU HW active residency:\s+([\d.]+)%`)
gpuIdleResidencyRe = regexp.MustCompile(`GPU idle residency:\s+([\d.]+)%`)
)
func (c *Collector) collectGPU(now time.Time) ([]GPUStatus, error) {
if runtime.GOOS == "darwin" {
// Static GPU info (cached 10 min).
if len(c.cachedGPU) == 0 || c.lastGPUAt.IsZero() || now.Sub(c.lastGPUAt) >= macGPUInfoTTL {
if gpus, err := readMacGPUInfo(); err == nil && len(gpus) > 0 {
c.cachedGPU = gpus
c.lastGPUAt = now
}
}
// Real-time GPU usage.
if len(c.cachedGPU) > 0 {
usage := getMacGPUUsage()
result := make([]GPUStatus, len(c.cachedGPU))
copy(result, c.cachedGPU)
// Apply usage to first GPU (Apple Silicon).
if len(result) > 0 {
result[0].Usage = usage
}
return result, nil
}
}
ctx, cancel := context.WithTimeout(context.Background(), 600*time.Millisecond)
defer cancel()
if !commandExists("nvidia-smi") {
return []GPUStatus{{
Name: "No GPU metrics available",
Note: "Install nvidia-smi or use platform-specific metrics",
}}, nil
}
out, err := runCmd(ctx, "nvidia-smi", "--query-gpu=utilization.gpu,memory.used,memory.total,name", "--format=csv,noheader,nounits")
if err != nil {
return nil, err
}
var gpus []GPUStatus
for line := range strings.Lines(strings.TrimSpace(out)) {
fields := strings.Split(line, ",")
if len(fields) < 4 {
continue
}
util, _ := strconv.ParseFloat(strings.TrimSpace(fields[0]), 64)
memUsed, _ := strconv.ParseFloat(strings.TrimSpace(fields[1]), 64)
memTotal, _ := strconv.ParseFloat(strings.TrimSpace(fields[2]), 64)
name := strings.TrimSpace(fields[3])
gpus = append(gpus, GPUStatus{
Name: name,
Usage: util,
MemoryUsed: memUsed,
MemoryTotal: memTotal,
})
}
if len(gpus) == 0 {
return []GPUStatus{{
Name: "GPU read failed",
Note: "Verify nvidia-smi availability",
}}, nil
}
return gpus, nil
}
func readMacGPUInfo() ([]GPUStatus, error) {
ctx, cancel := context.WithTimeout(context.Background(), systemProfilerTimeout)
defer cancel()
if !commandExists("system_profiler") {
return nil, errors.New("system_profiler unavailable")
}
out, err := runCmd(ctx, "system_profiler", "-json", "SPDisplaysDataType")
if err != nil {
return nil, err
}
var data struct {
Displays []struct {
Name string `json:"_name"`
VRAM string `json:"spdisplays_vram"`
Vendor string `json:"spdisplays_vendor"`
Metal string `json:"spdisplays_metal"`
Cores string `json:"sppci_cores"`
} `json:"SPDisplaysDataType"`
}
if err := json.Unmarshal([]byte(out), &data); err != nil {
return nil, err
}
var gpus []GPUStatus
for _, d := range data.Displays {
if d.Name == "" {
continue
}
noteParts := []string{}
if d.VRAM != "" {
noteParts = append(noteParts, "VRAM "+d.VRAM)
}
if d.Metal != "" {
noteParts = append(noteParts, d.Metal)
}
if d.Vendor != "" {
noteParts = append(noteParts, d.Vendor)
}
note := strings.Join(noteParts, " · ")
coreCount, _ := strconv.Atoi(d.Cores)
gpus = append(gpus, GPUStatus{
Name: d.Name,
Usage: -1, // Will be updated with real-time data
CoreCount: coreCount,
Note: note,
})
}
if len(gpus) == 0 {
return []GPUStatus{{
Name: "GPU info unavailable",
Note: "Unable to parse system_profiler output",
}}, nil
}
return gpus, nil
}
// getMacGPUUsage reads GPU active residency from powermetrics.
func getMacGPUUsage() float64 {
ctx, cancel := context.WithTimeout(context.Background(), powermetricsTimeout)
defer cancel()
// powermetrics may require root.
out, err := runCmd(ctx, "powermetrics", "--samplers", "gpu_power", "-i", "500", "-n", "1")
if err != nil {
return -1
}
// Parse "GPU HW active residency: X.XX%".
matches := gpuActiveResidencyRe.FindStringSubmatch(out)
if len(matches) >= 2 {
usage, err := strconv.ParseFloat(matches[1], 64)
if err == nil {
return usage
}
}
// Fallback: parse idle residency and derive active.
matchesIdle := gpuIdleResidencyRe.FindStringSubmatch(out)
if len(matchesIdle) >= 2 {
idle, err := strconv.ParseFloat(matchesIdle[1], 64)
if err == nil {
return 100.0 - idle
}
}
return -1
}

View File

@@ -1,137 +0,0 @@
package main
import (
"context"
"fmt"
"runtime"
"strings"
"time"
)
func collectHardware(totalRAM uint64, disks []DiskStatus) HardwareInfo {
if runtime.GOOS != "darwin" {
return HardwareInfo{
Model: "Unknown",
CPUModel: runtime.GOARCH,
TotalRAM: humanBytes(totalRAM),
DiskSize: "Unknown",
OSVersion: runtime.GOOS,
RefreshRate: "",
}
}
// Model and CPU from system_profiler.
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
var model, cpuModel, osVersion, refreshRate string
out, err := runCmd(ctx, "system_profiler", "SPHardwareDataType")
if err == nil {
for line := range strings.Lines(out) {
lower := strings.ToLower(strings.TrimSpace(line))
// Prefer "Model Name" over "Model Identifier".
if strings.Contains(lower, "model name:") {
parts := strings.Split(line, ":")
if len(parts) == 2 {
model = strings.TrimSpace(parts[1])
}
}
if strings.Contains(lower, "chip:") {
parts := strings.Split(line, ":")
if len(parts) == 2 {
cpuModel = strings.TrimSpace(parts[1])
}
}
if strings.Contains(lower, "processor name:") && cpuModel == "" {
parts := strings.Split(line, ":")
if len(parts) == 2 {
cpuModel = strings.TrimSpace(parts[1])
}
}
}
}
ctx2, cancel2 := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel2()
out2, err := runCmd(ctx2, "sw_vers", "-productVersion")
if err == nil {
osVersion = "macOS " + strings.TrimSpace(out2)
}
// Get refresh rate from display info (use mini detail to keep it fast).
ctx3, cancel3 := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel3()
out3, err := runCmd(ctx3, "system_profiler", "-detailLevel", "mini", "SPDisplaysDataType")
if err == nil {
refreshRate = parseRefreshRate(out3)
}
diskSize := "Unknown"
if len(disks) > 0 {
diskSize = humanBytes(disks[0].Total)
}
return HardwareInfo{
Model: model,
CPUModel: cpuModel,
TotalRAM: humanBytes(totalRAM),
DiskSize: diskSize,
OSVersion: osVersion,
RefreshRate: refreshRate,
}
}
// parseRefreshRate extracts the highest refresh rate from system_profiler display output.
func parseRefreshRate(output string) string {
maxHz := 0
for line := range strings.Lines(output) {
lower := strings.ToLower(line)
// Look for patterns like "@ 60Hz", "@ 60.00Hz", or "Refresh Rate: 120 Hz".
if strings.Contains(lower, "hz") {
fields := strings.Fields(lower)
for i, field := range fields {
if field == "hz" && i > 0 {
if hz := parseInt(fields[i-1]); hz > maxHz && hz < 500 {
maxHz = hz
}
continue
}
if numStr, ok := strings.CutSuffix(field, "hz"); ok {
if numStr == "" && i > 0 {
numStr = fields[i-1]
}
if hz := parseInt(numStr); hz > maxHz && hz < 500 {
maxHz = hz
}
}
}
}
}
if maxHz > 0 {
return fmt.Sprintf("%dHz", maxHz)
}
return ""
}
// parseInt safely parses an integer from a string.
func parseInt(s string) int {
// Trim away non-numeric padding, keep digits and '.' for decimals.
cleaned := strings.TrimSpace(s)
cleaned = strings.TrimLeftFunc(cleaned, func(r rune) bool {
return (r < '0' || r > '9') && r != '.'
})
cleaned = strings.TrimRightFunc(cleaned, func(r rune) bool {
return (r < '0' || r > '9') && r != '.'
})
if cleaned == "" {
return 0
}
var num int
if _, err := fmt.Sscanf(cleaned, "%d", &num); err != nil {
return 0
}
return num
}

View File

@@ -1,168 +0,0 @@
package main
import (
"fmt"
"strings"
)
// Health score weights and thresholds.
const (
// Weights.
healthCPUWeight = 30.0
healthMemWeight = 25.0
healthDiskWeight = 20.0
healthThermalWeight = 15.0
healthIOWeight = 10.0
// CPU.
cpuNormalThreshold = 30.0
cpuHighThreshold = 70.0
// Memory.
memNormalThreshold = 50.0
memHighThreshold = 80.0
memPressureWarnPenalty = 5.0
memPressureCritPenalty = 15.0
// Disk.
diskWarnThreshold = 70.0
diskCritThreshold = 90.0
// Thermal.
thermalNormalThreshold = 60.0
thermalHighThreshold = 85.0
// Disk IO (MB/s).
ioNormalThreshold = 50.0
ioHighThreshold = 150.0
)
func calculateHealthScore(cpu CPUStatus, mem MemoryStatus, disks []DiskStatus, diskIO DiskIOStatus, thermal ThermalStatus) (int, string) {
score := 100.0
issues := []string{}
// CPU penalty.
cpuPenalty := 0.0
if cpu.Usage > cpuNormalThreshold {
if cpu.Usage > cpuHighThreshold {
cpuPenalty = healthCPUWeight * (cpu.Usage - cpuNormalThreshold) / cpuHighThreshold
} else {
cpuPenalty = (healthCPUWeight / 2) * (cpu.Usage - cpuNormalThreshold) / (cpuHighThreshold - cpuNormalThreshold)
}
}
score -= cpuPenalty
if cpu.Usage > cpuHighThreshold {
issues = append(issues, "High CPU")
}
// Memory penalty.
memPenalty := 0.0
if mem.UsedPercent > memNormalThreshold {
if mem.UsedPercent > memHighThreshold {
memPenalty = healthMemWeight * (mem.UsedPercent - memNormalThreshold) / memNormalThreshold
} else {
memPenalty = (healthMemWeight / 2) * (mem.UsedPercent - memNormalThreshold) / (memHighThreshold - memNormalThreshold)
}
}
score -= memPenalty
if mem.UsedPercent > memHighThreshold {
issues = append(issues, "High Memory")
}
// Memory pressure penalty.
// Memory pressure penalty.
switch mem.Pressure {
case "warn":
score -= memPressureWarnPenalty
issues = append(issues, "Memory Pressure")
case "critical":
score -= memPressureCritPenalty
issues = append(issues, "Critical Memory")
}
// Disk penalty.
diskPenalty := 0.0
if len(disks) > 0 {
diskUsage := disks[0].UsedPercent
if diskUsage > diskWarnThreshold {
if diskUsage > diskCritThreshold {
diskPenalty = healthDiskWeight * (diskUsage - diskWarnThreshold) / (100 - diskWarnThreshold)
} else {
diskPenalty = (healthDiskWeight / 2) * (diskUsage - diskWarnThreshold) / (diskCritThreshold - diskWarnThreshold)
}
}
score -= diskPenalty
if diskUsage > diskCritThreshold {
issues = append(issues, "Disk Almost Full")
}
}
// Thermal penalty.
thermalPenalty := 0.0
if thermal.CPUTemp > 0 {
if thermal.CPUTemp > thermalNormalThreshold {
if thermal.CPUTemp > thermalHighThreshold {
thermalPenalty = healthThermalWeight
issues = append(issues, "Overheating")
} else {
thermalPenalty = healthThermalWeight * (thermal.CPUTemp - thermalNormalThreshold) / (thermalHighThreshold - thermalNormalThreshold)
}
}
score -= thermalPenalty
}
// Disk IO penalty.
ioPenalty := 0.0
totalIO := diskIO.ReadRate + diskIO.WriteRate
if totalIO > ioNormalThreshold {
if totalIO > ioHighThreshold {
ioPenalty = healthIOWeight
issues = append(issues, "Heavy Disk IO")
} else {
ioPenalty = healthIOWeight * (totalIO - ioNormalThreshold) / (ioHighThreshold - ioNormalThreshold)
}
}
score -= ioPenalty
// Clamp score.
if score < 0 {
score = 0
}
if score > 100 {
score = 100
}
// Build message.
var msg string
switch {
case score >= 90:
msg = "Excellent"
case score >= 75:
msg = "Good"
case score >= 60:
msg = "Fair"
case score >= 40:
msg = "Poor"
default:
msg = "Critical"
}
if len(issues) > 0 {
msg = msg + ": " + strings.Join(issues, ", ")
}
return int(score), msg
}
func formatUptime(secs uint64) string {
days := secs / 86400
hours := (secs % 86400) / 3600
mins := (secs % 3600) / 60
if days > 0 {
return fmt.Sprintf("%dd %dh %dm", days, hours, mins)
}
if hours > 0 {
return fmt.Sprintf("%dh %dm", hours, mins)
}
return fmt.Sprintf("%dm", mins)
}

View File

@@ -1,58 +0,0 @@
package main
import (
"strings"
"testing"
)
func TestCalculateHealthScorePerfect(t *testing.T) {
score, msg := calculateHealthScore(
CPUStatus{Usage: 10},
MemoryStatus{UsedPercent: 20, Pressure: "normal"},
[]DiskStatus{{UsedPercent: 30}},
DiskIOStatus{ReadRate: 5, WriteRate: 5},
ThermalStatus{CPUTemp: 40},
)
if score != 100 {
t.Fatalf("expected perfect score 100, got %d", score)
}
if msg != "Excellent" {
t.Fatalf("unexpected message %q", msg)
}
}
func TestCalculateHealthScoreDetectsIssues(t *testing.T) {
score, msg := calculateHealthScore(
CPUStatus{Usage: 95},
MemoryStatus{UsedPercent: 90, Pressure: "critical"},
[]DiskStatus{{UsedPercent: 95}},
DiskIOStatus{ReadRate: 120, WriteRate: 80},
ThermalStatus{CPUTemp: 90},
)
if score >= 40 {
t.Fatalf("expected heavy penalties bringing score down, got %d", score)
}
if msg == "Excellent" {
t.Fatalf("expected message to include issues, got %q", msg)
}
if !strings.Contains(msg, "High CPU") {
t.Fatalf("message should mention CPU issue: %q", msg)
}
if !strings.Contains(msg, "Disk Almost Full") {
t.Fatalf("message should mention disk issue: %q", msg)
}
}
func TestFormatUptime(t *testing.T) {
if got := formatUptime(65); got != "1m" {
t.Fatalf("expected 1m, got %s", got)
}
if got := formatUptime(3600 + 120); got != "1h 2m" {
t.Fatalf("expected \"1h 2m\", got %s", got)
}
if got := formatUptime(86400*2 + 3600*3 + 60*5); got != "2d 3h 5m" {
t.Fatalf("expected \"2d 3h 5m\", got %s", got)
}
}

View File

@@ -1,99 +0,0 @@
package main
import (
"context"
"runtime"
"strconv"
"strings"
"time"
"github.com/shirou/gopsutil/v3/mem"
)
func collectMemory() (MemoryStatus, error) {
vm, err := mem.VirtualMemory()
if err != nil {
return MemoryStatus{}, err
}
swap, _ := mem.SwapMemory()
pressure := getMemoryPressure()
// On macOS, vm.Cached is 0, so we calculate from file-backed pages.
cached := vm.Cached
if runtime.GOOS == "darwin" && cached == 0 {
cached = getFileBackedMemory()
}
return MemoryStatus{
Used: vm.Used,
Total: vm.Total,
UsedPercent: vm.UsedPercent,
SwapUsed: swap.Used,
SwapTotal: swap.Total,
Cached: cached,
Pressure: pressure,
}, nil
}
func getFileBackedMemory() uint64 {
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
out, err := runCmd(ctx, "vm_stat")
if err != nil {
return 0
}
// Parse page size from first line: "Mach Virtual Memory Statistics: (page size of 16384 bytes)"
var pageSize uint64 = 4096 // Default
firstLine := true
for line := range strings.Lines(out) {
if firstLine {
firstLine = false
if strings.Contains(line, "page size of") {
if _, after, found := strings.Cut(line, "page size of "); found {
if before, _, found := strings.Cut(after, " bytes"); found {
if size, err := strconv.ParseUint(strings.TrimSpace(before), 10, 64); err == nil {
pageSize = size
}
}
}
}
}
// Parse "File-backed pages: 388975."
if strings.Contains(line, "File-backed pages:") {
if _, after, found := strings.Cut(line, ":"); found {
numStr := strings.TrimSpace(after)
numStr = strings.TrimSuffix(numStr, ".")
if pages, err := strconv.ParseUint(numStr, 10, 64); err == nil {
return pages * pageSize
}
}
}
}
return 0
}
func getMemoryPressure() string {
if runtime.GOOS != "darwin" {
return ""
}
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
out, err := runCmd(ctx, "memory_pressure")
if err != nil {
return ""
}
lower := strings.ToLower(out)
if strings.Contains(lower, "critical") {
return "critical"
}
if strings.Contains(lower, "warn") {
return "warn"
}
if strings.Contains(lower, "normal") {
return "normal"
}
return ""
}

View File

@@ -1,142 +0,0 @@
package main
import (
"context"
"os"
"runtime"
"sort"
"strings"
"time"
"github.com/shirou/gopsutil/v3/net"
)
func (c *Collector) collectNetwork(now time.Time) ([]NetworkStatus, error) {
stats, err := net.IOCounters(true)
if err != nil {
return nil, err
}
// Map interface IPs.
ifAddrs := getInterfaceIPs()
if c.lastNetAt.IsZero() {
c.lastNetAt = now
for _, s := range stats {
c.prevNet[s.Name] = s
}
return nil, nil
}
elapsed := now.Sub(c.lastNetAt).Seconds()
if elapsed <= 0 {
elapsed = 1
}
var result []NetworkStatus
for _, cur := range stats {
if isNoiseInterface(cur.Name) {
continue
}
prev, ok := c.prevNet[cur.Name]
if !ok {
continue
}
rx := float64(cur.BytesRecv-prev.BytesRecv) / 1024.0 / 1024.0 / elapsed
tx := float64(cur.BytesSent-prev.BytesSent) / 1024.0 / 1024.0 / elapsed
if rx < 0 {
rx = 0
}
if tx < 0 {
tx = 0
}
result = append(result, NetworkStatus{
Name: cur.Name,
RxRateMBs: rx,
TxRateMBs: tx,
IP: ifAddrs[cur.Name],
})
}
c.lastNetAt = now
for _, s := range stats {
c.prevNet[s.Name] = s
}
sort.Slice(result, func(i, j int) bool {
return result[i].RxRateMBs+result[i].TxRateMBs > result[j].RxRateMBs+result[j].TxRateMBs
})
if len(result) > 3 {
result = result[:3]
}
return result, nil
}
func getInterfaceIPs() map[string]string {
result := make(map[string]string)
ifaces, err := net.Interfaces()
if err != nil {
return result
}
for _, iface := range ifaces {
for _, addr := range iface.Addrs {
// IPv4 only.
if strings.Contains(addr.Addr, ".") && !strings.HasPrefix(addr.Addr, "127.") {
ip := strings.Split(addr.Addr, "/")[0]
result[iface.Name] = ip
break
}
}
}
return result
}
func isNoiseInterface(name string) bool {
lower := strings.ToLower(name)
noiseList := []string{"lo", "awdl", "utun", "llw", "bridge", "gif", "stf", "xhc", "anpi", "ap"}
for _, prefix := range noiseList {
if strings.HasPrefix(lower, prefix) {
return true
}
}
return false
}
func collectProxy() ProxyStatus {
// Check environment variables first.
for _, env := range []string{"https_proxy", "HTTPS_PROXY", "http_proxy", "HTTP_PROXY"} {
if val := os.Getenv(env); val != "" {
proxyType := "HTTP"
if strings.HasPrefix(val, "socks") {
proxyType = "SOCKS"
}
// Extract host.
host := val
if strings.Contains(host, "://") {
host = strings.SplitN(host, "://", 2)[1]
}
if idx := strings.Index(host, "@"); idx >= 0 {
host = host[idx+1:]
}
return ProxyStatus{Enabled: true, Type: proxyType, Host: host}
}
}
// macOS: check system proxy via scutil.
if runtime.GOOS == "darwin" {
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
out, err := runCmd(ctx, "scutil", "--proxy")
if err == nil {
if strings.Contains(out, "HTTPEnable : 1") || strings.Contains(out, "HTTPSEnable : 1") {
return ProxyStatus{Enabled: true, Type: "System", Host: "System Proxy"}
}
if strings.Contains(out, "SOCKSEnable : 1") {
return ProxyStatus{Enabled: true, Type: "SOCKS", Host: "System Proxy"}
}
}
}
return ProxyStatus{Enabled: false}
}

View File

@@ -1,53 +0,0 @@
package main
import (
"context"
"runtime"
"strconv"
"strings"
"time"
)
func collectTopProcesses() []ProcessInfo {
if runtime.GOOS != "darwin" {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
// Use ps to get top processes by CPU.
out, err := runCmd(ctx, "ps", "-Aceo", "pcpu,pmem,comm", "-r")
if err != nil {
return nil
}
var procs []ProcessInfo
i := 0
for line := range strings.Lines(strings.TrimSpace(out)) {
if i == 0 {
i++
continue
}
if i > 5 {
break
}
i++
fields := strings.Fields(line)
if len(fields) < 3 {
continue
}
cpuVal, _ := strconv.ParseFloat(fields[0], 64)
memVal, _ := strconv.ParseFloat(fields[1], 64)
name := fields[len(fields)-1]
// Strip path from command name.
if idx := strings.LastIndex(name, "/"); idx >= 0 {
name = name[idx+1:]
}
procs = append(procs, ProcessInfo{
Name: name,
CPU: cpuVal,
Memory: memVal,
})
}
return procs
}

View File

@@ -1,758 +0,0 @@
package main
import (
"fmt"
"sort"
"strconv"
"strings"
"github.com/charmbracelet/lipgloss"
)
var (
titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#C79FD7")).Bold(true)
subtleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#737373"))
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD75F"))
dangerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5F5F")).Bold(true)
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#A5D6A7"))
lineStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#404040"))
primaryStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#BD93F9"))
)
const (
colWidth = 38
iconCPU = "◉"
iconMemory = "◫"
iconGPU = "◧"
iconDisk = "▥"
iconNetwork = "⇅"
iconBattery = "◪"
iconSensors = "◈"
iconProcs = "❊"
)
// Mole body frames (facing right).
var moleBody = [][]string{
{
` /\_/\`,
` ___/ o o \`,
`/___ =-= /`,
`\____)-m-m)`,
},
{
` /\_/\`,
` ___/ o o \`,
`/___ =-= /`,
`\____)mm__)`,
},
{
` /\_/\`,
` ___/ · · \`,
`/___ =-= /`,
`\___)-m__m)`,
},
{
` /\_/\`,
` ___/ o o \`,
`/___ =-= /`,
`\____)-mm-)`,
},
}
// Mirror mole body frames (facing left).
var moleBodyMirror = [][]string{
{
` /\_/\`,
` / o o \___`,
` \ =-= ___\`,
` (m-m-(____/`,
},
{
` /\_/\`,
` / o o \___`,
` \ =-= ___\`,
` (__mm(____/`,
},
{
` /\_/\`,
` / · · \___`,
` \ =-= ___\`,
` (m__m-(___/`,
},
{
` /\_/\`,
` / o o \___`,
` \ =-= ___\`,
` (-mm-(____/`,
},
}
// getMoleFrame renders the animated mole.
func getMoleFrame(animFrame int, termWidth int) string {
moleWidth := 15
maxPos := max(termWidth-moleWidth, 0)
cycleLength := maxPos * 2
if cycleLength == 0 {
cycleLength = 1
}
pos := animFrame % cycleLength
movingLeft := pos > maxPos
if movingLeft {
pos = cycleLength - pos
}
// Use mirror frames when moving left
var frames [][]string
if movingLeft {
frames = moleBodyMirror
} else {
frames = moleBody
}
bodyIdx := animFrame % len(frames)
body := frames[bodyIdx]
padding := strings.Repeat(" ", pos)
var lines []string
for _, line := range body {
lines = append(lines, padding+line)
}
return strings.Join(lines, "\n")
}
type cardData struct {
icon string
title string
lines []string
}
func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int, catHidden bool) string {
title := titleStyle.Render("Mole Status")
scoreStyle := getScoreStyle(m.HealthScore)
scoreText := subtleStyle.Render("Health ") + scoreStyle.Render(fmt.Sprintf("● %d", m.HealthScore))
// Hardware info for a single line.
infoParts := []string{}
if m.Hardware.Model != "" {
infoParts = append(infoParts, primaryStyle.Render(m.Hardware.Model))
}
if m.Hardware.CPUModel != "" {
cpuInfo := m.Hardware.CPUModel
// Append GPU core count when available.
if len(m.GPU) > 0 && m.GPU[0].CoreCount > 0 {
cpuInfo += fmt.Sprintf(" (%dGPU)", m.GPU[0].CoreCount)
}
infoParts = append(infoParts, cpuInfo)
}
var specs []string
if m.Hardware.TotalRAM != "" {
specs = append(specs, m.Hardware.TotalRAM)
}
if m.Hardware.DiskSize != "" {
specs = append(specs, m.Hardware.DiskSize)
}
if len(specs) > 0 {
infoParts = append(infoParts, strings.Join(specs, "/"))
}
if m.Hardware.RefreshRate != "" {
infoParts = append(infoParts, m.Hardware.RefreshRate)
}
if m.Hardware.OSVersion != "" {
infoParts = append(infoParts, m.Hardware.OSVersion)
}
headerLine := title + " " + scoreText + " " + strings.Join(infoParts, " · ")
// Show cat unless hidden
var mole string
if !catHidden {
mole = getMoleFrame(animFrame, termWidth)
}
if errMsg != "" {
if mole == "" {
return lipgloss.JoinVertical(lipgloss.Left, headerLine, "", dangerStyle.Render("ERROR: "+errMsg), "")
}
return lipgloss.JoinVertical(lipgloss.Left, headerLine, "", mole, dangerStyle.Render("ERROR: "+errMsg), "")
}
if mole == "" {
return headerLine
}
return headerLine + "\n" + mole
}
func getScoreStyle(score int) lipgloss.Style {
switch {
case score >= 90:
return lipgloss.NewStyle().Foreground(lipgloss.Color("#87FF87")).Bold(true)
case score >= 75:
return lipgloss.NewStyle().Foreground(lipgloss.Color("#87D787")).Bold(true)
case score >= 60:
return lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD75F")).Bold(true)
case score >= 40:
return lipgloss.NewStyle().Foreground(lipgloss.Color("#FFAF5F")).Bold(true)
default:
return lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Bold(true)
}
}
func buildCards(m MetricsSnapshot, _ int) []cardData {
cards := []cardData{
renderCPUCard(m.CPU),
renderMemoryCard(m.Memory),
renderDiskCard(m.Disks, m.DiskIO),
renderBatteryCard(m.Batteries, m.Thermal),
renderProcessCard(m.TopProcesses),
renderNetworkCard(m.Network, m.Proxy),
}
if hasSensorData(m.Sensors) {
cards = append(cards, renderSensorsCard(m.Sensors))
}
return cards
}
func hasSensorData(sensors []SensorReading) bool {
for _, s := range sensors {
if s.Note == "" && s.Value > 0 {
return true
}
}
return false
}
func renderCPUCard(cpu CPUStatus) cardData {
var lines []string
lines = append(lines, fmt.Sprintf("Total %s %5.1f%%", progressBar(cpu.Usage), cpu.Usage))
if cpu.PerCoreEstimated {
lines = append(lines, subtleStyle.Render("Per-core data unavailable (using averaged load)"))
} else if len(cpu.PerCore) > 0 {
type coreUsage struct {
idx int
val float64
}
var cores []coreUsage
for i, v := range cpu.PerCore {
cores = append(cores, coreUsage{i, v})
}
sort.Slice(cores, func(i, j int) bool { return cores[i].val > cores[j].val })
maxCores := min(len(cores), 3)
for i := 0; i < maxCores; i++ {
c := cores[i]
lines = append(lines, fmt.Sprintf("Core%-2d %s %5.1f%%", c.idx+1, progressBar(c.val), c.val))
}
}
// Load line at the end
if cpu.PCoreCount > 0 && cpu.ECoreCount > 0 {
lines = append(lines, fmt.Sprintf("Load %.2f / %.2f / %.2f (%dP+%dE)",
cpu.Load1, cpu.Load5, cpu.Load15, cpu.PCoreCount, cpu.ECoreCount))
} else {
lines = append(lines, fmt.Sprintf("Load %.2f / %.2f / %.2f (%d cores)",
cpu.Load1, cpu.Load5, cpu.Load15, cpu.LogicalCPU))
}
return cardData{icon: iconCPU, title: "CPU", lines: lines}
}
func renderMemoryCard(mem MemoryStatus) cardData {
// Check if swap is being used (or at least allocated).
hasSwap := mem.SwapTotal > 0 || mem.SwapUsed > 0
var lines []string
// Line 1: Used
lines = append(lines, fmt.Sprintf("Used %s %5.1f%%", progressBar(mem.UsedPercent), mem.UsedPercent))
// Line 2: Free
freePercent := 100 - mem.UsedPercent
lines = append(lines, fmt.Sprintf("Free %s %5.1f%%", progressBar(freePercent), freePercent))
if hasSwap {
// Layout with Swap:
// 3. Swap (progress bar + text)
// 4. Total
// 5. Avail
var swapPercent float64
if mem.SwapTotal > 0 {
swapPercent = (float64(mem.SwapUsed) / float64(mem.SwapTotal)) * 100.0
}
swapText := fmt.Sprintf("(%s/%s)", humanBytesCompact(mem.SwapUsed), humanBytesCompact(mem.SwapTotal))
lines = append(lines, fmt.Sprintf("Swap %s %5.1f%% %s", progressBar(swapPercent), swapPercent, swapText))
lines = append(lines, fmt.Sprintf("Total %s / %s", humanBytes(mem.Used), humanBytes(mem.Total)))
lines = append(lines, fmt.Sprintf("Avail %s", humanBytes(mem.Total-mem.Used))) // Simplified avail logic for consistency
} else {
// Layout without Swap:
// 3. Total
// 4. Cached (if > 0)
// 5. Avail
lines = append(lines, fmt.Sprintf("Total %s / %s", humanBytes(mem.Used), humanBytes(mem.Total)))
if mem.Cached > 0 {
lines = append(lines, fmt.Sprintf("Cached %s", humanBytes(mem.Cached)))
}
// Calculate available if not provided directly, or use Total-Used as proxy if needed,
// but typically available is more nuanced. Using what we have.
// Re-calculating available based on logic if needed, but mem.Total - mem.Used is often "Avail"
// in simple terms for this view or we could use the passed definition.
// Original code calculated: available := mem.Total - mem.Used
available := mem.Total - mem.Used
lines = append(lines, fmt.Sprintf("Avail %s", humanBytes(available)))
}
// Memory pressure status.
if mem.Pressure != "" {
pressureStyle := okStyle
pressureText := "Status " + mem.Pressure
switch mem.Pressure {
case "warn":
pressureStyle = warnStyle
case "critical":
pressureStyle = dangerStyle
}
lines = append(lines, pressureStyle.Render(pressureText))
}
return cardData{icon: iconMemory, title: "Memory", lines: lines}
}
func renderDiskCard(disks []DiskStatus, io DiskIOStatus) cardData {
var lines []string
if len(disks) == 0 {
lines = append(lines, subtleStyle.Render("Collecting..."))
} else {
internal, external := splitDisks(disks)
addGroup := func(prefix string, list []DiskStatus) {
if len(list) == 0 {
return
}
for i, d := range list {
label := diskLabel(prefix, i, len(list))
lines = append(lines, formatDiskLine(label, d))
}
}
addGroup("INTR", internal)
addGroup("EXTR", external)
if len(lines) == 0 {
lines = append(lines, subtleStyle.Render("No disks detected"))
}
}
readBar := ioBar(io.ReadRate)
writeBar := ioBar(io.WriteRate)
lines = append(lines, fmt.Sprintf("Read %s %.1f MB/s", readBar, io.ReadRate))
lines = append(lines, fmt.Sprintf("Write %s %.1f MB/s", writeBar, io.WriteRate))
return cardData{icon: iconDisk, title: "Disk", lines: lines}
}
func splitDisks(disks []DiskStatus) (internal, external []DiskStatus) {
for _, d := range disks {
if d.External {
external = append(external, d)
} else {
internal = append(internal, d)
}
}
return internal, external
}
func diskLabel(prefix string, index int, total int) string {
if total <= 1 {
return prefix
}
return fmt.Sprintf("%s%d", prefix, index+1)
}
func formatDiskLine(label string, d DiskStatus) string {
if label == "" {
label = "DISK"
}
bar := progressBar(d.UsedPercent)
used := humanBytesShort(d.Used)
total := humanBytesShort(d.Total)
return fmt.Sprintf("%-6s %s %5.1f%% (%s/%s)", label, bar, d.UsedPercent, used, total)
}
func ioBar(rate float64) string {
filled := min(int(rate/10.0), 5)
if filled < 0 {
filled = 0
}
bar := strings.Repeat("▮", filled) + strings.Repeat("▯", 5-filled)
if rate > 80 {
return dangerStyle.Render(bar)
}
if rate > 30 {
return warnStyle.Render(bar)
}
return okStyle.Render(bar)
}
func renderProcessCard(procs []ProcessInfo) cardData {
var lines []string
maxProcs := 3
for i, p := range procs {
if i >= maxProcs {
break
}
name := shorten(p.Name, 12)
cpuBar := miniBar(p.CPU)
lines = append(lines, fmt.Sprintf("%-12s %s %5.1f%%", name, cpuBar, p.CPU))
}
if len(lines) == 0 {
lines = append(lines, subtleStyle.Render("No data"))
}
return cardData{icon: iconProcs, title: "Processes", lines: lines}
}
func miniBar(percent float64) string {
filled := min(int(percent/20), 5)
if filled < 0 {
filled = 0
}
return colorizePercent(percent, strings.Repeat("▮", filled)+strings.Repeat("▯", 5-filled))
}
func renderNetworkCard(netStats []NetworkStatus, proxy ProxyStatus) cardData {
var lines []string
var totalRx, totalTx float64
var primaryIP string
for _, n := range netStats {
totalRx += n.RxRateMBs
totalTx += n.TxRateMBs
if primaryIP == "" && n.IP != "" && n.Name == "en0" {
primaryIP = n.IP
}
}
if len(netStats) == 0 {
lines = []string{subtleStyle.Render("Collecting...")}
} else {
rxBar := netBar(totalRx)
txBar := netBar(totalTx)
lines = append(lines, fmt.Sprintf("Down %s %s", rxBar, formatRate(totalRx)))
lines = append(lines, fmt.Sprintf("Up %s %s", txBar, formatRate(totalTx)))
// Show proxy and IP on one line.
var infoParts []string
if proxy.Enabled {
infoParts = append(infoParts, "Proxy "+proxy.Type)
}
if primaryIP != "" {
infoParts = append(infoParts, primaryIP)
}
if len(infoParts) > 0 {
lines = append(lines, strings.Join(infoParts, " · "))
}
}
return cardData{icon: iconNetwork, title: "Network", lines: lines}
}
func netBar(rate float64) string {
filled := min(int(rate/2.0), 5)
if filled < 0 {
filled = 0
}
bar := strings.Repeat("▮", filled) + strings.Repeat("▯", 5-filled)
if rate > 8 {
return dangerStyle.Render(bar)
}
if rate > 3 {
return warnStyle.Render(bar)
}
return okStyle.Render(bar)
}
func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData {
var lines []string
if len(batts) == 0 {
lines = append(lines, subtleStyle.Render("No battery"))
} else {
b := batts[0]
statusLower := strings.ToLower(b.Status)
percentText := fmt.Sprintf("%5.1f%%", b.Percent)
if b.Percent < 20 && statusLower != "charging" && statusLower != "charged" {
percentText = dangerStyle.Render(percentText)
}
lines = append(lines, fmt.Sprintf("Level %s %s", batteryProgressBar(b.Percent), percentText))
// Add capacity line if available.
if b.Capacity > 0 {
capacityText := fmt.Sprintf("%5d%%", b.Capacity)
if b.Capacity < 70 {
capacityText = dangerStyle.Render(capacityText)
} else if b.Capacity < 85 {
capacityText = warnStyle.Render(capacityText)
}
lines = append(lines, fmt.Sprintf("Health %s %s", batteryProgressBar(float64(b.Capacity)), capacityText))
}
statusIcon := ""
statusStyle := subtleStyle
if statusLower == "charging" || statusLower == "charged" {
statusIcon = " ⚡"
statusStyle = okStyle
} else if b.Percent < 20 {
statusStyle = dangerStyle
}
statusText := b.Status
if len(statusText) > 0 {
statusText = strings.ToUpper(statusText[:1]) + strings.ToLower(statusText[1:])
}
if b.TimeLeft != "" {
statusText += " · " + b.TimeLeft
}
// Add power info.
if statusLower == "charging" || statusLower == "charged" {
if thermal.SystemPower > 0 {
statusText += fmt.Sprintf(" · %.0fW", thermal.SystemPower)
} else if thermal.AdapterPower > 0 {
statusText += fmt.Sprintf(" · %.0fW Adapter", thermal.AdapterPower)
}
} else if thermal.BatteryPower > 0 {
// Only show battery power when discharging (positive value)
statusText += fmt.Sprintf(" · %.0fW", thermal.BatteryPower)
}
lines = append(lines, statusStyle.Render(statusText+statusIcon))
healthParts := []string{}
if b.Health != "" {
healthParts = append(healthParts, b.Health)
}
if b.CycleCount > 0 {
healthParts = append(healthParts, fmt.Sprintf("%d cycles", b.CycleCount))
}
if thermal.CPUTemp > 0 {
tempText := fmt.Sprintf("%.0f°C", thermal.CPUTemp)
if thermal.CPUTemp > 80 {
tempText = dangerStyle.Render(tempText)
} else if thermal.CPUTemp > 60 {
tempText = warnStyle.Render(tempText)
}
healthParts = append(healthParts, tempText)
}
if thermal.FanSpeed > 0 {
healthParts = append(healthParts, fmt.Sprintf("%d RPM", thermal.FanSpeed))
}
if len(healthParts) > 0 {
lines = append(lines, strings.Join(healthParts, " · "))
}
}
return cardData{icon: iconBattery, title: "Power", lines: lines}
}
func renderSensorsCard(sensors []SensorReading) cardData {
var lines []string
for _, s := range sensors {
if s.Note != "" {
continue
}
lines = append(lines, fmt.Sprintf("%-12s %s", shorten(s.Label, 12), colorizeTemp(s.Value)+s.Unit))
}
if len(lines) == 0 {
lines = append(lines, subtleStyle.Render("No sensors"))
}
return cardData{icon: iconSensors, title: "Sensors", lines: lines}
}
func renderCard(data cardData, width int, height int) string {
titleText := data.icon + " " + data.title
lineLen := max(width-lipgloss.Width(titleText)-2, 4)
header := titleStyle.Render(titleText) + " " + lineStyle.Render(strings.Repeat("╌", lineLen))
content := header + "\n" + strings.Join(data.lines, "\n")
lines := strings.Split(content, "\n")
for len(lines) < height {
lines = append(lines, "")
}
return strings.Join(lines, "\n")
}
func progressBar(percent float64) string {
total := 16
if percent < 0 {
percent = 0
}
if percent > 100 {
percent = 100
}
filled := int(percent / 100 * float64(total))
var builder strings.Builder
for i := range total {
if i < filled {
builder.WriteString("█")
} else {
builder.WriteString("░")
}
}
return colorizePercent(percent, builder.String())
}
func batteryProgressBar(percent float64) string {
total := 16
if percent < 0 {
percent = 0
}
if percent > 100 {
percent = 100
}
filled := int(percent / 100 * float64(total))
var builder strings.Builder
for i := range total {
if i < filled {
builder.WriteString("█")
} else {
builder.WriteString("░")
}
}
return colorizeBattery(percent, builder.String())
}
func colorizePercent(percent float64, s string) string {
switch {
case percent >= 85:
return dangerStyle.Render(s)
case percent >= 60:
return warnStyle.Render(s)
default:
return okStyle.Render(s)
}
}
func colorizeBattery(percent float64, s string) string {
switch {
case percent < 20:
return dangerStyle.Render(s)
case percent < 50:
return warnStyle.Render(s)
default:
return okStyle.Render(s)
}
}
func colorizeTemp(t float64) string {
switch {
case t >= 85:
return dangerStyle.Render(fmt.Sprintf("%.1f", t))
case t >= 70:
return warnStyle.Render(fmt.Sprintf("%.1f", t))
default:
return subtleStyle.Render(fmt.Sprintf("%.1f", t))
}
}
func formatRate(mb float64) string {
if mb < 0.01 {
return "0 MB/s"
}
if mb < 1 {
return fmt.Sprintf("%.2f MB/s", mb)
}
if mb < 10 {
return fmt.Sprintf("%.1f MB/s", mb)
}
return fmt.Sprintf("%.0f MB/s", mb)
}
func humanBytes(v uint64) string {
switch {
case v > 1<<40:
return fmt.Sprintf("%.1f TB", float64(v)/(1<<40))
case v > 1<<30:
return fmt.Sprintf("%.1f GB", float64(v)/(1<<30))
case v > 1<<20:
return fmt.Sprintf("%.1f MB", float64(v)/(1<<20))
case v > 1<<10:
return fmt.Sprintf("%.1f KB", float64(v)/(1<<10))
default:
return strconv.FormatUint(v, 10) + " B"
}
}
func humanBytesShort(v uint64) string {
switch {
case v >= 1<<40:
return fmt.Sprintf("%.0fT", float64(v)/(1<<40))
case v >= 1<<30:
return fmt.Sprintf("%.0fG", float64(v)/(1<<30))
case v >= 1<<20:
return fmt.Sprintf("%.0fM", float64(v)/(1<<20))
case v >= 1<<10:
return fmt.Sprintf("%.0fK", float64(v)/(1<<10))
default:
return strconv.FormatUint(v, 10)
}
}
func humanBytesCompact(v uint64) string {
switch {
case v >= 1<<40:
return fmt.Sprintf("%.1fT", float64(v)/(1<<40))
case v >= 1<<30:
return fmt.Sprintf("%.1fG", float64(v)/(1<<30))
case v >= 1<<20:
return fmt.Sprintf("%.1fM", float64(v)/(1<<20))
case v >= 1<<10:
return fmt.Sprintf("%.1fK", float64(v)/(1<<10))
default:
return strconv.FormatUint(v, 10)
}
}
func shorten(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen-1] + "…"
}
func renderTwoColumns(cards []cardData, width int) string {
if len(cards) == 0 {
return ""
}
cw := colWidth
if width > 0 && width/2-2 > cw {
cw = width/2 - 2
}
var rows []string
for i := 0; i < len(cards); i += 2 {
left := renderCard(cards[i], cw, 0)
right := ""
if i+1 < len(cards) {
right = renderCard(cards[i+1], cw, 0)
}
targetHeight := maxInt(lipgloss.Height(left), lipgloss.Height(right))
left = renderCard(cards[i], cw, targetHeight)
if right != "" {
right = renderCard(cards[i+1], cw, targetHeight)
rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right))
} else {
rows = append(rows, left)
}
}
var spacedRows []string
for i, r := range rows {
if i > 0 {
spacedRows = append(spacedRows, "")
}
spacedRows = append(spacedRows, r)
}
return lipgloss.JoinVertical(lipgloss.Left, spacedRows...)
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}