mirror of
https://github.com/tw93/Mole.git
synced 2026-02-04 15:39:42 +00:00
Merge branch 'dev'
This commit is contained in:
15
README.md
15
README.md
@@ -70,14 +70,11 @@ mo purge --paths # Configure project scan directories
|
||||
|
||||
## Tips
|
||||
|
||||
- **Terminal**: For best results, use Alacritty, kitty, WezTerm, Ghostty, or Warp. iTerm2 may have compatibility issues.
|
||||
- **Safety**: Built with strict protections. See our [Security Audit](SECURITY_AUDIT.md). Preview changes with `mo clean --dry-run`.
|
||||
- **Whitelist**: Manage protected paths with `mo clean --whitelist`.
|
||||
- **Touch ID**: Enable Touch ID for sudo commands by running `mo touchid`.
|
||||
- **Shell Completion**: Enable tab completion by running `mo completion` — auto-detects your shell.
|
||||
- **Navigation**: Supports standard arrow keys and Vim bindings (`h/j/k/l`).
|
||||
- **Debug**: View detailed logs by appending the `--debug` flag, like `mo clean --debug`.
|
||||
- **Detailed Preview**: Combine `--dry-run --debug` for comprehensive operation details including risk levels, file paths, sizes, and expected outcomes. Check `~/.config/mole/mole_debug_session.log` for full details.
|
||||
- **Terminal**: iTerm2 has known compatibility issues; we recommend Alacritty, kitty, WezTerm, Ghostty, or Warp.
|
||||
- **Safety**: Built with strict protections. See [Security Audit](SECURITY_AUDIT.md). Preview changes with `mo clean --dry-run`.
|
||||
- **Debug Mode**: Use `--debug` for detailed logs (e.g., `mo clean --debug`). Combine with `--dry-run` for comprehensive preview including risk levels and file details.
|
||||
- **Navigation**: Supports arrow keys and Vim bindings (`h/j/k/l`).
|
||||
- **Configuration**: Run `mo touchid` for Touch ID sudo, `mo completion` for shell tab completion, `mo clean --whitelist` to manage protected paths.
|
||||
|
||||
## Features in Detail
|
||||
|
||||
@@ -188,7 +185,7 @@ Up ▮▯▯▯▯ 0.8 MB/s Chrome ▮▮▮▯▯ 2
|
||||
Proxy HTTP · 192.168.1.100 Terminal ▮▯▯▯▯ 12.5%
|
||||
```
|
||||
|
||||
Health score is based on CPU, memory, disk, temperature, and I/O load, color-coded by severity.
|
||||
Health score based on CPU, memory, disk, temperature, and I/O load. Color-coded by range. Press `k` to hide/show cat, `q` to quit.
|
||||
|
||||
### Project Artifact Purge
|
||||
|
||||
|
||||
@@ -93,15 +93,12 @@ func humanizeBytes(size int64) string {
|
||||
return fmt.Sprintf("%.1f %cB", value, "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
func coloredProgressBar(value, max int64, percent float64) string {
|
||||
if max <= 0 {
|
||||
func coloredProgressBar(value, maxValue int64, percent float64) string {
|
||||
if maxValue <= 0 {
|
||||
return colorGray + strings.Repeat("░", barWidth) + colorReset
|
||||
}
|
||||
|
||||
filled := int((value * int64(barWidth)) / max)
|
||||
if filled > barWidth {
|
||||
filled = barWidth
|
||||
}
|
||||
filled := min(int((value*int64(barWidth))/maxValue), barWidth)
|
||||
|
||||
var barColor string
|
||||
if percent >= 50 {
|
||||
@@ -114,26 +111,27 @@ func coloredProgressBar(value, max int64, percent float64) string {
|
||||
barColor = colorGreen
|
||||
}
|
||||
|
||||
bar := barColor
|
||||
for i := 0; i < barWidth; i++ {
|
||||
var bar strings.Builder
|
||||
bar.WriteString(barColor)
|
||||
for i := range barWidth {
|
||||
if i < filled {
|
||||
if i < filled-1 {
|
||||
bar += "█"
|
||||
bar.WriteString("█")
|
||||
} else {
|
||||
remainder := (value * int64(barWidth)) % max
|
||||
if remainder > max/2 {
|
||||
bar += "█"
|
||||
} else if remainder > max/4 {
|
||||
bar += "▓"
|
||||
remainder := (value * int64(barWidth)) % maxValue
|
||||
if remainder > maxValue/2 {
|
||||
bar.WriteString("█")
|
||||
} else if remainder > maxValue/4 {
|
||||
bar.WriteString("▓")
|
||||
} else {
|
||||
bar += "▒"
|
||||
bar.WriteString("▒")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
bar += colorGray + "░" + barColor
|
||||
bar.WriteString(colorGray + "░" + barColor)
|
||||
}
|
||||
}
|
||||
return bar + colorReset
|
||||
return bar.String() + colorReset
|
||||
}
|
||||
|
||||
// runeWidth returns display width for wide characters and emoji.
|
||||
@@ -181,10 +179,6 @@ func calculateNameWidth(termWidth int) int {
|
||||
return available
|
||||
}
|
||||
|
||||
func trimName(name string) string {
|
||||
return trimNameWithWidth(name, 45) // Default width for backward compatibility
|
||||
}
|
||||
|
||||
func trimNameWithWidth(name string, maxWidth int) string {
|
||||
const (
|
||||
ellipsis = "..."
|
||||
|
||||
@@ -7,11 +7,11 @@ 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 interface{}) {
|
||||
func (h *entryHeap) Push(x any) {
|
||||
*h = append(*h, x.(dirEntry))
|
||||
}
|
||||
|
||||
func (h *entryHeap) Pop() interface{} {
|
||||
func (h *entryHeap) Pop() any {
|
||||
old := *h
|
||||
n := len(old)
|
||||
x := old[n-1]
|
||||
@@ -26,11 +26,11 @@ 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 interface{}) {
|
||||
func (h *largeFileHeap) Push(x any) {
|
||||
*h = append(*h, x.(fileEntry))
|
||||
}
|
||||
|
||||
func (h *largeFileHeap) Pop() interface{} {
|
||||
func (h *largeFileHeap) Pop() any {
|
||||
old := *h
|
||||
n := len(old)
|
||||
x := old[n-1]
|
||||
|
||||
@@ -148,7 +148,7 @@ func main() {
|
||||
go prefetchOverviewCache(prefetchCtx)
|
||||
|
||||
p := tea.NewProgram(newModel(abs, isOverview), tea.WithAltScreen())
|
||||
if err := p.Start(); err != nil {
|
||||
if _, err := p.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "analyzer error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -359,7 +359,7 @@ func (m model) scanCmd(path string) tea.Cmd {
|
||||
return scanResultMsg{result: result, err: nil}
|
||||
}
|
||||
|
||||
v, err, _ := scanGroup.Do(path, func() (interface{}, error) {
|
||||
v, err, _ := scanGroup.Do(path, func() (any, error) {
|
||||
return scanPathConcurrent(path, m.filesScanned, m.dirsScanned, m.bytesScanned, m.currentPath)
|
||||
})
|
||||
|
||||
@@ -997,10 +997,7 @@ func (m *model) clampEntrySelection() {
|
||||
m.selected = 0
|
||||
}
|
||||
viewport := calculateViewport(m.height, false)
|
||||
maxOffset := len(m.entries) - viewport
|
||||
if maxOffset < 0 {
|
||||
maxOffset = 0
|
||||
}
|
||||
maxOffset := max(len(m.entries)-viewport, 0)
|
||||
if m.offset > maxOffset {
|
||||
m.offset = maxOffset
|
||||
}
|
||||
@@ -1025,10 +1022,7 @@ func (m *model) clampLargeSelection() {
|
||||
m.largeSelected = 0
|
||||
}
|
||||
viewport := calculateViewport(m.height, true)
|
||||
maxOffset := len(m.largeFiles) - viewport
|
||||
if maxOffset < 0 {
|
||||
maxOffset = 0
|
||||
}
|
||||
maxOffset := max(len(m.largeFiles)-viewport, 0)
|
||||
if m.largeOffset > maxOffset {
|
||||
m.largeOffset = maxOffset
|
||||
}
|
||||
|
||||
@@ -39,10 +39,7 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
|
||||
heap.Init(largeFilesHeap)
|
||||
|
||||
// Worker pool sized for I/O-bound scanning.
|
||||
numWorkers := runtime.NumCPU() * cpuMultiplier
|
||||
if numWorkers < minWorkers {
|
||||
numWorkers = minWorkers
|
||||
}
|
||||
numWorkers := max(runtime.NumCPU()*cpuMultiplier, minWorkers)
|
||||
if numWorkers > maxWorkers {
|
||||
numWorkers = maxWorkers
|
||||
}
|
||||
@@ -289,10 +286,7 @@ func calculateDirSizeFast(root string, filesScanned, dirsScanned, bytesScanned *
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
concurrency := runtime.NumCPU() * 4
|
||||
if concurrency > 64 {
|
||||
concurrency = 64
|
||||
}
|
||||
concurrency := min(runtime.NumCPU()*4, 64)
|
||||
sem := make(chan struct{}, concurrency)
|
||||
|
||||
var walk func(string)
|
||||
@@ -363,10 +357,9 @@ func findLargeFilesWithSpotlight(root string, minSize int64) []fileEntry {
|
||||
return nil
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
var files []fileEntry
|
||||
|
||||
for _, line := range lines {
|
||||
for line := range strings.Lines(strings.TrimSpace(string(output))) {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
@@ -413,8 +406,8 @@ func findLargeFilesWithSpotlight(root string, minSize int64) []fileEntry {
|
||||
|
||||
// isInFoldedDir checks if a path is inside a folded directory.
|
||||
func isInFoldedDir(path string) bool {
|
||||
parts := strings.Split(path, string(os.PathSeparator))
|
||||
for _, part := range parts {
|
||||
parts := strings.SplitSeq(path, string(os.PathSeparator))
|
||||
for part := range parts {
|
||||
if foldDirs[part] {
|
||||
return true
|
||||
}
|
||||
@@ -432,10 +425,7 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, fil
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Limit concurrent subdirectory scans.
|
||||
maxConcurrent := runtime.NumCPU() * 2
|
||||
if maxConcurrent > maxDirWorkers {
|
||||
maxConcurrent = maxDirWorkers
|
||||
}
|
||||
maxConcurrent := min(runtime.NumCPU()*2, maxDirWorkers)
|
||||
sem := make(chan struct{}, maxConcurrent)
|
||||
|
||||
for _, child := range children {
|
||||
|
||||
@@ -100,14 +100,8 @@ func (m model) View() string {
|
||||
fmt.Fprintln(&b, " No large files found (>=100MB)")
|
||||
} else {
|
||||
viewport := calculateViewport(m.height, true)
|
||||
start := m.largeOffset
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end := start + viewport
|
||||
if end > len(m.largeFiles) {
|
||||
end = len(m.largeFiles)
|
||||
}
|
||||
start := max(m.largeOffset, 0)
|
||||
end := min(start+viewport, len(m.largeFiles))
|
||||
maxLargeSize := int64(1)
|
||||
for _, file := range m.largeFiles {
|
||||
if file.Size > maxLargeSize {
|
||||
@@ -163,10 +157,7 @@ func (m model) View() string {
|
||||
for idx, entry := range m.entries {
|
||||
icon := "📁"
|
||||
sizeVal := entry.Size
|
||||
barValue := sizeVal
|
||||
if barValue < 0 {
|
||||
barValue = 0
|
||||
}
|
||||
barValue := max(sizeVal, 0)
|
||||
var percent float64
|
||||
if totalSize > 0 && sizeVal >= 0 {
|
||||
percent = float64(sizeVal) / float64(totalSize) * 100
|
||||
@@ -243,14 +234,8 @@ func (m model) View() string {
|
||||
|
||||
viewport := calculateViewport(m.height, false)
|
||||
nameWidth := calculateNameWidth(m.width)
|
||||
start := m.offset
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end := start + viewport
|
||||
if end > len(m.entries) {
|
||||
end = len(m.entries)
|
||||
}
|
||||
start := max(m.offset, 0)
|
||||
end := min(start+viewport, len(m.entries))
|
||||
|
||||
for idx := start; idx < end; idx++ {
|
||||
entry := m.entries[idx]
|
||||
|
||||
@@ -34,11 +34,13 @@ type model struct {
|
||||
lastUpdated time.Time
|
||||
collecting bool
|
||||
animFrame int
|
||||
catHidden bool // true = hidden, false = visible
|
||||
}
|
||||
|
||||
func newModel() model {
|
||||
return model{
|
||||
collector: NewCollector(),
|
||||
catHidden: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +54,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "q", "esc", "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "k":
|
||||
// Toggle cat visibility
|
||||
m.catHidden = !m.catHidden
|
||||
return m, nil
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
@@ -89,7 +95,7 @@ func (m model) View() string {
|
||||
return "Loading..."
|
||||
}
|
||||
|
||||
header := renderHeader(m.metrics, m.errMessage, m.animFrame, m.width)
|
||||
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)
|
||||
@@ -104,10 +110,20 @@ func (m model) View() string {
|
||||
}
|
||||
rendered = append(rendered, renderCard(c, cardWidth, 0))
|
||||
}
|
||||
return header + "\n" + lipgloss.JoinVertical(lipgloss.Left, rendered...)
|
||||
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
|
||||
}
|
||||
|
||||
return header + "\n" + renderTwoColumns(cards, m.width)
|
||||
twoCol := renderTwoColumns(cards, m.width)
|
||||
// Add extra newline if cat is hidden for better spacing
|
||||
if m.catHidden {
|
||||
return header + "\n\n" + twoCol
|
||||
}
|
||||
return header + "\n" + twoCol
|
||||
}
|
||||
|
||||
func (m model) collectCmd() tea.Cmd {
|
||||
@@ -127,16 +143,13 @@ func animTick() tea.Cmd {
|
||||
|
||||
func animTickWithSpeed(cpuUsage float64) tea.Cmd {
|
||||
// Higher CPU = faster animation.
|
||||
interval := 300 - int(cpuUsage*2.5)
|
||||
if interval < 50 {
|
||||
interval = 50
|
||||
}
|
||||
interval := max(300-int(cpuUsage*2.5), 50)
|
||||
return tea.Tick(time.Duration(interval)*time.Millisecond, func(time.Time) tea.Msg { return animTickMsg{} })
|
||||
}
|
||||
|
||||
func main() {
|
||||
p := tea.NewProgram(newModel(), tea.WithAltScreen())
|
||||
if err := p.Start(); err != nil {
|
||||
if _, err := p.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "system status error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -68,11 +68,10 @@ func collectBatteries() (batts []BatteryStatus, err error) {
|
||||
}
|
||||
|
||||
func parsePMSet(raw string, health string, cycles int, capacity int) []BatteryStatus {
|
||||
lines := strings.Split(raw, "\n")
|
||||
var out []BatteryStatus
|
||||
var timeLeft string
|
||||
|
||||
for _, line := range lines {
|
||||
for line := range strings.Lines(raw) {
|
||||
// Time remaining.
|
||||
if strings.Contains(line, "remaining") {
|
||||
parts := strings.Fields(line)
|
||||
@@ -128,8 +127,7 @@ func getCachedPowerData() (health string, cycles int, capacity int) {
|
||||
return "", 0, 0
|
||||
}
|
||||
|
||||
lines := strings.Split(out, "\n")
|
||||
for _, line := range lines {
|
||||
for line := range strings.Lines(out) {
|
||||
lower := strings.ToLower(line)
|
||||
if strings.Contains(lower, "cycle count") {
|
||||
if _, after, found := strings.Cut(line, ":"); found {
|
||||
@@ -183,8 +181,7 @@ func collectThermal() ThermalStatus {
|
||||
// Fan info from cached system_profiler.
|
||||
out := getSystemPowerOutput()
|
||||
if out != "" {
|
||||
lines := strings.Split(out, "\n")
|
||||
for _, line := range lines {
|
||||
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 {
|
||||
@@ -200,8 +197,7 @@ func collectThermal() ThermalStatus {
|
||||
ctxPower, cancelPower := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
defer cancelPower()
|
||||
if out, err := runCmd(ctxPower, "ioreg", "-rn", "AppleSmartBattery"); err == nil {
|
||||
lines := strings.Split(out, "\n")
|
||||
for _, line := range lines {
|
||||
for line := range strings.Lines(out) {
|
||||
line = strings.TrimSpace(line)
|
||||
|
||||
// Battery temperature ("Temperature" = 3055).
|
||||
@@ -242,8 +238,9 @@ func collectThermal() ThermalStatus {
|
||||
valStr, _, _ = strings.Cut(valStr, ",")
|
||||
valStr, _, _ = strings.Cut(valStr, "}")
|
||||
valStr = strings.TrimSpace(valStr)
|
||||
if powerMW, err := strconv.ParseFloat(valStr, 64); err == nil {
|
||||
thermal.BatteryPower = powerMW / 1000.0
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,13 +68,12 @@ func readBluetoothCTLDevices() ([]BluetoothDevice, error) {
|
||||
}
|
||||
|
||||
func parseSPBluetooth(raw string) []BluetoothDevice {
|
||||
lines := strings.Split(raw, "\n")
|
||||
var devices []BluetoothDevice
|
||||
var currentName string
|
||||
var connected bool
|
||||
var battery string
|
||||
|
||||
for _, line := range lines {
|
||||
for line := range strings.Lines(raw) {
|
||||
trim := strings.TrimSpace(line)
|
||||
if len(trim) == 0 {
|
||||
continue
|
||||
@@ -112,10 +111,9 @@ func parseSPBluetooth(raw string) []BluetoothDevice {
|
||||
}
|
||||
|
||||
func parseBluetoothctl(raw string) []BluetoothDevice {
|
||||
lines := strings.Split(raw, "\n")
|
||||
var devices []BluetoothDevice
|
||||
current := BluetoothDevice{}
|
||||
for _, line := range lines {
|
||||
for line := range strings.Lines(raw) {
|
||||
trim := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trim, "Device ") {
|
||||
if current.Name != "" {
|
||||
@@ -123,8 +121,8 @@ func parseBluetoothctl(raw string) []BluetoothDevice {
|
||||
}
|
||||
current = BluetoothDevice{Name: strings.TrimPrefix(trim, "Device "), Connected: false}
|
||||
}
|
||||
if strings.HasPrefix(trim, "Name:") {
|
||||
current.Name = strings.TrimSpace(strings.TrimPrefix(trim, "Name:"))
|
||||
if after, ok := strings.CutPrefix(trim, "Name:"); ok {
|
||||
current.Name = strings.TrimSpace(after)
|
||||
}
|
||||
if strings.HasPrefix(trim, "Connected:") {
|
||||
current.Connected = strings.Contains(trim, "yes")
|
||||
|
||||
@@ -119,7 +119,10 @@ func getCoreTopology() (pCores, eCores int) {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(out), "\n")
|
||||
var lines []string
|
||||
for line := range strings.Lines(strings.TrimSpace(out)) {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
if len(lines) < 4 {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ func isExternalDisk(device string) (bool, error) {
|
||||
found bool
|
||||
external bool
|
||||
)
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
for line := range strings.Lines(out) {
|
||||
trim := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trim, "Internal:") {
|
||||
found = true
|
||||
|
||||
@@ -61,9 +61,8 @@ func (c *Collector) collectGPU(now time.Time) ([]GPUStatus, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(out), "\n")
|
||||
var gpus []GPUStatus
|
||||
for _, line := range lines {
|
||||
for line := range strings.Lines(strings.TrimSpace(out)) {
|
||||
fields := strings.Split(line, ",")
|
||||
if len(fields) < 4 {
|
||||
continue
|
||||
|
||||
@@ -28,8 +28,7 @@ func collectHardware(totalRAM uint64, disks []DiskStatus) HardwareInfo {
|
||||
|
||||
out, err := runCmd(ctx, "system_profiler", "SPHardwareDataType")
|
||||
if err == nil {
|
||||
lines := strings.Split(out, "\n")
|
||||
for _, line := range lines {
|
||||
for line := range strings.Lines(out) {
|
||||
lower := strings.ToLower(strings.TrimSpace(line))
|
||||
// Prefer "Model Name" over "Model Identifier".
|
||||
if strings.Contains(lower, "model name:") {
|
||||
@@ -85,10 +84,9 @@ func collectHardware(totalRAM uint64, disks []DiskStatus) HardwareInfo {
|
||||
|
||||
// parseRefreshRate extracts the highest refresh rate from system_profiler display output.
|
||||
func parseRefreshRate(output string) string {
|
||||
lines := strings.Split(output, "\n")
|
||||
maxHz := 0
|
||||
|
||||
for _, line := range lines {
|
||||
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") {
|
||||
@@ -100,8 +98,7 @@ func parseRefreshRate(output string) string {
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(field, "hz") {
|
||||
numStr := strings.TrimSuffix(field, "hz")
|
||||
if numStr, ok := strings.CutSuffix(field, "hz"); ok {
|
||||
if numStr == "" && i > 0 {
|
||||
numStr = fields[i-1]
|
||||
}
|
||||
|
||||
@@ -46,22 +46,22 @@ func getFileBackedMemory() uint64 {
|
||||
|
||||
// Parse page size from first line: "Mach Virtual Memory Statistics: (page size of 16384 bytes)"
|
||||
var pageSize uint64 = 4096 // Default
|
||||
lines := strings.Split(out, "\n")
|
||||
if len(lines) > 0 {
|
||||
firstLine := lines[0]
|
||||
if strings.Contains(firstLine, "page size of") {
|
||||
if _, after, found := strings.Cut(firstLine, "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
|
||||
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."
|
||||
for _, line := range lines {
|
||||
// Parse "File-backed pages: 388975."
|
||||
if strings.Contains(line, "File-backed pages:") {
|
||||
if _, after, found := strings.Cut(line, ":"); found {
|
||||
numStr := strings.TrimSpace(after)
|
||||
|
||||
@@ -21,15 +21,17 @@ func collectTopProcesses() []ProcessInfo {
|
||||
return nil
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(out), "\n")
|
||||
var procs []ProcessInfo
|
||||
for i, line := range lines {
|
||||
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
|
||||
|
||||
@@ -32,7 +32,7 @@ const (
|
||||
iconProcs = "❊"
|
||||
)
|
||||
|
||||
// Mole body frames.
|
||||
// Mole body frames (facing right).
|
||||
var moleBody = [][]string{
|
||||
{
|
||||
` /\_/\`,
|
||||
@@ -60,26 +60,60 @@ var moleBody = [][]string{
|
||||
},
|
||||
}
|
||||
|
||||
// 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 {
|
||||
bodyIdx := animFrame % len(moleBody)
|
||||
body := moleBody[bodyIdx]
|
||||
|
||||
moleWidth := 15
|
||||
maxPos := termWidth - moleWidth
|
||||
if maxPos < 0 {
|
||||
maxPos = 0
|
||||
}
|
||||
maxPos := max(termWidth-moleWidth, 0)
|
||||
|
||||
cycleLength := maxPos * 2
|
||||
if cycleLength == 0 {
|
||||
cycleLength = 1
|
||||
}
|
||||
pos := animFrame % cycleLength
|
||||
if pos > maxPos {
|
||||
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
|
||||
|
||||
@@ -96,7 +130,7 @@ type cardData struct {
|
||||
lines []string
|
||||
}
|
||||
|
||||
func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int) string {
|
||||
func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int, catHidden bool) string {
|
||||
title := titleStyle.Render("Mole Status")
|
||||
|
||||
scoreStyle := getScoreStyle(m.HealthScore)
|
||||
@@ -134,11 +168,21 @@ func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int
|
||||
|
||||
headerLine := title + " " + scoreText + " " + strings.Join(infoParts, " · ")
|
||||
|
||||
mole := getMoleFrame(animFrame, termWidth)
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -197,10 +241,7 @@ func renderCPUCard(cpu CPUStatus) cardData {
|
||||
}
|
||||
sort.Slice(cores, func(i, j int) bool { return cores[i].val > cores[j].val })
|
||||
|
||||
maxCores := 3
|
||||
if len(cores) < maxCores {
|
||||
maxCores = len(cores)
|
||||
}
|
||||
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))
|
||||
@@ -219,28 +260,6 @@ func renderCPUCard(cpu CPUStatus) cardData {
|
||||
return cardData{icon: iconCPU, title: "CPU", lines: lines}
|
||||
}
|
||||
|
||||
func renderGPUCard(gpus []GPUStatus) cardData {
|
||||
var lines []string
|
||||
if len(gpus) == 0 {
|
||||
lines = append(lines, subtleStyle.Render("No GPU detected"))
|
||||
} else {
|
||||
for _, g := range gpus {
|
||||
if g.Usage >= 0 {
|
||||
lines = append(lines, fmt.Sprintf("Total %s %5.1f%%", progressBar(g.Usage), g.Usage))
|
||||
}
|
||||
coreInfo := ""
|
||||
if g.CoreCount > 0 {
|
||||
coreInfo = fmt.Sprintf(" (%d cores)", g.CoreCount)
|
||||
}
|
||||
lines = append(lines, g.Name+coreInfo)
|
||||
if g.Usage < 0 {
|
||||
lines = append(lines, subtleStyle.Render("Run with sudo for usage metrics"))
|
||||
}
|
||||
}
|
||||
}
|
||||
return cardData{icon: iconGPU, title: "GPU", lines: lines}
|
||||
}
|
||||
|
||||
func renderMemoryCard(mem MemoryStatus) cardData {
|
||||
// Check if swap is being used (or at least allocated).
|
||||
hasSwap := mem.SwapTotal > 0 || mem.SwapUsed > 0
|
||||
@@ -356,10 +375,7 @@ func formatDiskLine(label string, d DiskStatus) string {
|
||||
}
|
||||
|
||||
func ioBar(rate float64) string {
|
||||
filled := int(rate / 10.0)
|
||||
if filled > 5 {
|
||||
filled = 5
|
||||
}
|
||||
filled := min(int(rate/10.0), 5)
|
||||
if filled < 0 {
|
||||
filled = 0
|
||||
}
|
||||
@@ -391,10 +407,7 @@ func renderProcessCard(procs []ProcessInfo) cardData {
|
||||
}
|
||||
|
||||
func miniBar(percent float64) string {
|
||||
filled := int(percent / 20)
|
||||
if filled > 5 {
|
||||
filled = 5
|
||||
}
|
||||
filled := min(int(percent/20), 5)
|
||||
if filled < 0 {
|
||||
filled = 0
|
||||
}
|
||||
@@ -437,10 +450,7 @@ func renderNetworkCard(netStats []NetworkStatus, proxy ProxyStatus) cardData {
|
||||
}
|
||||
|
||||
func netBar(rate float64) string {
|
||||
filled := int(rate / 2.0)
|
||||
if filled > 5 {
|
||||
filled = 5
|
||||
}
|
||||
filled := min(int(rate/2.0), 5)
|
||||
if filled < 0 {
|
||||
filled = 0
|
||||
}
|
||||
@@ -501,6 +511,7 @@ func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData {
|
||||
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))
|
||||
@@ -551,10 +562,7 @@ func renderSensorsCard(sensors []SensorReading) cardData {
|
||||
|
||||
func renderCard(data cardData, width int, height int) string {
|
||||
titleText := data.icon + " " + data.title
|
||||
lineLen := width - lipgloss.Width(titleText) - 2
|
||||
if lineLen < 4 {
|
||||
lineLen = 4
|
||||
}
|
||||
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")
|
||||
|
||||
@@ -576,7 +584,7 @@ func progressBar(percent float64) string {
|
||||
filled := int(percent / 100 * float64(total))
|
||||
|
||||
var builder strings.Builder
|
||||
for i := 0; i < total; i++ {
|
||||
for i := range total {
|
||||
if i < filled {
|
||||
builder.WriteString("█")
|
||||
} else {
|
||||
@@ -597,7 +605,7 @@ func batteryProgressBar(percent float64) string {
|
||||
filled := int(percent / 100 * float64(total))
|
||||
|
||||
var builder strings.Builder
|
||||
for i := 0; i < total; i++ {
|
||||
for i := range total {
|
||||
if i < filled {
|
||||
builder.WriteString("█")
|
||||
} else {
|
||||
|
||||
@@ -70,6 +70,7 @@ declare -a DEFAULT_WHITELIST_PATTERNS=(
|
||||
"$HOME/Library/Caches/pypoetry/virtualenvs*"
|
||||
"$HOME/Library/Caches/JetBrains*"
|
||||
"$HOME/Library/Caches/com.jetbrains.toolbox*"
|
||||
"$HOME/Library/Application Support/JetBrains*"
|
||||
"$HOME/Library/Caches/com.apple.finder"
|
||||
"$HOME/Library/Mobile Documents*"
|
||||
# System-critical caches that affect macOS functionality and stability
|
||||
|
||||
@@ -96,9 +96,8 @@ ask_for_updates() {
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}Tip:${NC} Homebrew: brew upgrade / brew upgrade --cask"
|
||||
echo -e "${YELLOW}Tip:${NC} App Store: open App Store → Updates"
|
||||
echo -e "${YELLOW}Tip:${NC} macOS: System Settings → General → Software Update"
|
||||
echo -e "${YELLOW}💡 Run ${GREEN}brew upgrade${YELLOW} to update${NC}"
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
|
||||
@@ -85,7 +85,8 @@ Xcode archives (built app packages)|$HOME/Library/Developer/Xcode/Archives/*|ide
|
||||
Xcode internal cache files|$HOME/Library/Caches/com.apple.dt.Xcode/*|ide_cache
|
||||
Xcode iOS device support symbols|$HOME/Library/Developer/Xcode/iOS DeviceSupport/*/Symbols/System/Library/Caches/*|ide_cache
|
||||
Maven local repository (Java dependencies)|$HOME/.m2/repository/*|ide_cache
|
||||
JetBrains IDEs cache (IntelliJ, PyCharm, WebStorm)|$HOME/Library/Caches/JetBrains/*|ide_cache
|
||||
JetBrains IDEs data (IntelliJ, PyCharm, WebStorm, GoLand)|$HOME/Library/Application Support/JetBrains/*|ide_cache
|
||||
JetBrains IDEs cache|$HOME/Library/Caches/JetBrains/*|ide_cache
|
||||
Android Studio cache and indexes|$HOME/Library/Caches/Google/AndroidStudio*/*|ide_cache
|
||||
Android build cache|$HOME/.android/build-cache/*|ide_cache
|
||||
VS Code runtime cache|$HOME/Library/Application Support/Code/Cache/*|ide_cache
|
||||
|
||||
Reference in New Issue
Block a user