Skip to content

Commit a20d8bf

Browse files
authored
feat(analyze): hidden-space insights in overview
feat(analyze): hidden-space insights in overview
2 parents 02eed20 + ac23128 commit a20d8bf

File tree

4 files changed

+299
-3
lines changed

4 files changed

+299
-3
lines changed

cmd/analyze/insights.go

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
//go:build darwin
2+
3+
package main
4+
5+
import (
6+
"context"
7+
"os"
8+
"os/exec"
9+
"path/filepath"
10+
"strconv"
11+
"strings"
12+
"time"
13+
)
14+
15+
// createInsightEntries returns the list of hidden-space insight entries
16+
// to show in the overview screen alongside the standard directory entries.
17+
func createInsightEntries() []dirEntry {
18+
home := os.Getenv("HOME")
19+
if home == "" {
20+
return nil
21+
}
22+
23+
var entries []dirEntry
24+
25+
// iOS Backups — ~/Library/Application Support/MobileSync/Backup
26+
backupPath := filepath.Join(home, "Library", "Application Support", "MobileSync", "Backup")
27+
if info, err := os.Stat(backupPath); err == nil && info.IsDir() {
28+
entries = append(entries, dirEntry{
29+
Name: "iOS Backups",
30+
Path: backupPath,
31+
IsDir: true,
32+
Size: -1,
33+
})
34+
}
35+
36+
// Old Downloads — ~/Downloads (files older than 90 days)
37+
downloadsPath := filepath.Join(home, "Downloads")
38+
if info, err := os.Stat(downloadsPath); err == nil && info.IsDir() {
39+
entries = append(entries, dirEntry{
40+
Name: "Old Downloads (90d+)",
41+
Path: downloadsPath,
42+
IsDir: true,
43+
Size: -1,
44+
})
45+
}
46+
47+
// Cleanable paths — things mo clean can remove or the user can safely delete.
48+
cleanablePaths := []struct {
49+
name string
50+
path string
51+
}{
52+
// Universal (everyone has these)
53+
{"System Caches", filepath.Join(home, "Library", "Caches")},
54+
{"System Logs", filepath.Join(home, "Library", "Logs")},
55+
{"Homebrew Cache", filepath.Join(home, "Library", "Caches", "Homebrew")},
56+
57+
// Developer-specific (only shown if path exists)
58+
{"Xcode DerivedData", filepath.Join(home, "Library", "Developer", "Xcode", "DerivedData")},
59+
{"Xcode Simulators", filepath.Join(home, "Library", "Developer", "CoreSimulator", "Devices")},
60+
{"Xcode Archives", filepath.Join(home, "Library", "Developer", "Xcode", "Archives")},
61+
{"Spotify Cache", filepath.Join(home, "Library", "Application Support", "Spotify", "PersistentCache")},
62+
{"JetBrains Cache", filepath.Join(home, "Library", "Caches", "JetBrains")},
63+
{"Docker Data", filepath.Join(home, "Library", "Containers", "com.docker.docker", "Data")},
64+
{"pip Cache", filepath.Join(home, "Library", "Caches", "pip")},
65+
{"Gradle Cache", filepath.Join(home, ".gradle", "caches")},
66+
{"CocoaPods Cache", filepath.Join(home, "Library", "Caches", "CocoaPods")},
67+
}
68+
cacheBreakdownPaths := cleanablePaths
69+
for _, c := range cacheBreakdownPaths {
70+
if info, err := os.Stat(c.path); err == nil && info.IsDir() {
71+
entries = append(entries, dirEntry{
72+
Name: c.name,
73+
Path: c.path,
74+
IsDir: true,
75+
Size: -1,
76+
})
77+
}
78+
}
79+
80+
return entries
81+
}
82+
83+
// measureInsightSize measures the size of an insight entry.
84+
// Some insights need special measurement (e.g., Old Downloads only counts old files).
85+
func measureInsightSize(entry dirEntry) (int64, error) {
86+
home := os.Getenv("HOME")
87+
88+
// Old Downloads: only count files older than 90 days.
89+
if home != "" && entry.Path == filepath.Join(home, "Downloads") {
90+
return measureOldDownloads(entry.Path, 90)
91+
}
92+
93+
// All others: standard directory size measurement.
94+
return measureOverviewSize(entry.Path)
95+
}
96+
97+
// measureOldDownloads calculates total size of files in a directory
98+
// that haven't been modified in the given number of days.
99+
func measureOldDownloads(dir string, daysOld int) (int64, error) {
100+
cutoff := time.Now().AddDate(0, 0, -daysOld)
101+
var total int64
102+
103+
entries, err := os.ReadDir(dir)
104+
if err != nil {
105+
return 0, err
106+
}
107+
108+
for _, entry := range entries {
109+
// Skip hidden files.
110+
if strings.HasPrefix(entry.Name(), ".") {
111+
continue
112+
}
113+
114+
info, err := entry.Info()
115+
if err != nil {
116+
continue
117+
}
118+
119+
if info.ModTime().Before(cutoff) {
120+
if entry.IsDir() {
121+
// Use du for directories.
122+
if size, err := getDirSizeFast(filepath.Join(dir, entry.Name())); err == nil {
123+
total += size
124+
}
125+
} else {
126+
total += info.Size()
127+
}
128+
}
129+
}
130+
131+
return total, nil
132+
}
133+
134+
// insightIcon returns an appropriate icon for an overview entry.
135+
func insightIcon(entry dirEntry) string {
136+
switch entry.Name {
137+
case "iOS Backups":
138+
return "📱"
139+
case "Old Downloads (90d+)":
140+
return "📥"
141+
case "System Caches", "Homebrew Cache", "pip Cache", "CocoaPods Cache", "Gradle Cache":
142+
return "💾"
143+
case "System Logs":
144+
return "📋"
145+
case "Xcode DerivedData", "Xcode Archives":
146+
return "🔨"
147+
case "Xcode Simulators":
148+
return "📲"
149+
case "Spotify Cache", "JetBrains Cache":
150+
return "💾"
151+
case "Docker Data":
152+
return "🐳"
153+
default:
154+
return "📁"
155+
}
156+
}
157+
158+
// getDirSizeFast measures directory size using du.
159+
func getDirSizeFast(path string) (int64, error) {
160+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
161+
defer cancel()
162+
163+
cmd := exec.CommandContext(ctx, "du", "-sk", path)
164+
output, err := cmd.Output()
165+
if err != nil {
166+
return 0, err
167+
}
168+
169+
fields := strings.Fields(string(output))
170+
if len(fields) == 0 {
171+
return 0, nil
172+
}
173+
174+
kb, err := strconv.ParseInt(fields[0], 10, 64)
175+
if err != nil {
176+
return 0, err
177+
}
178+
179+
return kb * 1024, nil
180+
}

cmd/analyze/insights_test.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
//go:build darwin
2+
3+
package main
4+
5+
import (
6+
"os"
7+
"path/filepath"
8+
"testing"
9+
"time"
10+
)
11+
12+
func TestCreateInsightEntries(t *testing.T) {
13+
entries := createInsightEntries()
14+
// Should return at least some entries on a real Mac.
15+
// iOS Backups may not exist, but Old Downloads and Mail Data likely do.
16+
if len(entries) == 0 {
17+
t.Log("No insight entries found (some paths may not exist on this machine)")
18+
}
19+
20+
// Verify all entries have required fields.
21+
for _, e := range entries {
22+
if e.Name == "" {
23+
t.Error("insight entry has empty Name")
24+
}
25+
if e.Path == "" {
26+
t.Error("insight entry has empty Path")
27+
}
28+
if e.Size != -1 {
29+
t.Errorf("insight entry %q should have Size=-1 (pending), got %d", e.Name, e.Size)
30+
}
31+
if !e.IsDir {
32+
t.Errorf("insight entry %q should be a directory", e.Name)
33+
}
34+
}
35+
}
36+
37+
func TestInsightIcon(t *testing.T) {
38+
tests := []struct {
39+
name string
40+
want string
41+
}{
42+
{"iOS Backups", "📱"},
43+
{"Old Downloads (90d+)", "📥"},
44+
{"System Caches", "💾"},
45+
{"System Logs", "📋"},
46+
{"Xcode Simulators", "📲"},
47+
{"Docker Data", "🐳"},
48+
{"Home", "📁"},
49+
{"Applications", "📁"},
50+
}
51+
for _, tt := range tests {
52+
t.Run(tt.name, func(t *testing.T) {
53+
got := insightIcon(dirEntry{Name: tt.name})
54+
if got != tt.want {
55+
t.Errorf("insightIcon(%q) = %q, want %q", tt.name, got, tt.want)
56+
}
57+
})
58+
}
59+
}
60+
61+
func TestMeasureOldDownloads(t *testing.T) {
62+
// Create a temp directory with old and new files.
63+
dir := t.TempDir()
64+
65+
// Create an old file (set mtime to 100 days ago).
66+
oldFile := filepath.Join(dir, "old.txt")
67+
if err := os.WriteFile(oldFile, []byte("old content here"), 0644); err != nil {
68+
t.Fatal(err)
69+
}
70+
oldTime := time.Now().AddDate(0, 0, -100)
71+
os.Chtimes(oldFile, oldTime, oldTime)
72+
73+
// Create a new file.
74+
newFile := filepath.Join(dir, "new.txt")
75+
if err := os.WriteFile(newFile, []byte("new content"), 0644); err != nil {
76+
t.Fatal(err)
77+
}
78+
79+
size, err := measureOldDownloads(dir, 90)
80+
if err != nil {
81+
t.Fatalf("measureOldDownloads: %v", err)
82+
}
83+
84+
if size == 0 {
85+
t.Error("expected non-zero size for old files")
86+
}
87+
88+
// Size should be approximately the size of old.txt (16 bytes) but not new.txt.
89+
if size > 1024 {
90+
t.Errorf("size %d seems too large for a 16-byte file", size)
91+
}
92+
}
93+
94+
func TestMeasureInsightSizeFallsBackToOverview(t *testing.T) {
95+
// For a non-Downloads path, measureInsightSize should use measureOverviewSize.
96+
dir := t.TempDir()
97+
testFile := filepath.Join(dir, "test.dat")
98+
if err := os.WriteFile(testFile, make([]byte, 4096), 0644); err != nil {
99+
t.Fatal(err)
100+
}
101+
102+
size, err := measureInsightSize(dirEntry{Path: dir})
103+
if err != nil {
104+
t.Fatalf("measureInsightSize: %v", err)
105+
}
106+
if size == 0 {
107+
t.Error("expected non-zero size")
108+
}
109+
}

cmd/analyze/main.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,9 @@ func createOverviewEntries() []dirEntry {
246246
dirEntry{Name: "System Library", Path: "/Library", IsDir: true, Size: -1},
247247
)
248248

249+
// Hidden space insights — paths that silently accumulate disk usage.
250+
entries = append(entries, createInsightEntries()...)
251+
249252
return entries
250253
}
251254

@@ -1154,7 +1157,11 @@ func (m *model) removePathFromView(path string) {
11541157

11551158
func scanOverviewPathCmd(path string, index int) tea.Cmd {
11561159
return func() tea.Msg {
1157-
size, err := measureOverviewSize(path)
1160+
var size int64
1161+
var err error
1162+
1163+
size, err = measureInsightSize(dirEntry{Path: path})
1164+
11581165
return overviewSizeMsg{
11591166
Path: path,
11601167
Index: index,

cmd/analyze/view.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,9 +168,9 @@ func (m model) View() string {
168168
}
169169
totalSize := m.totalSize
170170
// Overview paths are short; fixed width keeps layout stable.
171-
nameWidth := 20
171+
nameWidth := 22
172172
for idx, entry := range m.entries {
173-
icon := "📁"
173+
icon := insightIcon(entry)
174174
sizeVal := entry.Size
175175
barValue := max(sizeVal, 0)
176176
var percent float64

0 commit comments

Comments
 (0)