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:
BIN
cmd/analyze/analyze.exe
Normal file
BIN
cmd/analyze/analyze.exe
Normal file
Binary file not shown.
@@ -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, ¤t)
|
||||
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, ¤t)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error scanning locked directory, got nil")
|
||||
}
|
||||
if !os.IsPermission(err) {
|
||||
t.Logf("unexpected error type: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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", '1', 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", "123", 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
1695
cmd/analyze/main.go
1695
cmd/analyze/main.go
File diff suppressed because it is too large
Load Diff
164
cmd/analyze/main_test.go
Normal file
164
cmd/analyze/main_test.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
219
cmd/status/main_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user