mirror of
https://github.com/tw93/Mole.git
synced 2026-02-13 00:25:14 +00:00
feat: Enhance clean, optimize, analyze, and status commands, and update security audit documentation.
This commit is contained in:
@@ -75,11 +75,6 @@ func TestScanPathConcurrentBasic(t *testing.T) {
|
||||
if bytes := atomic.LoadInt64(&bytesScanned); bytes == 0 {
|
||||
t.Fatalf("expected byte counter to increase")
|
||||
}
|
||||
// current path update is throttled, so it might be empty for small scans
|
||||
// if current == "" {
|
||||
// t.Fatalf("expected current path to be updated")
|
||||
// }
|
||||
|
||||
foundSymlink := false
|
||||
for _, entry := range result.Entries {
|
||||
if strings.HasSuffix(entry.Name, " →") {
|
||||
@@ -148,7 +143,7 @@ func TestOverviewStoreAndLoad(t *testing.T) {
|
||||
t.Fatalf("snapshot mismatch: want %d, got %d", want, got)
|
||||
}
|
||||
|
||||
// Force reload from disk and ensure value persists.
|
||||
// Reload from disk and ensure value persists.
|
||||
resetOverviewSnapshotForTest()
|
||||
got, err = loadStoredOverviewSize(path)
|
||||
if err != nil {
|
||||
@@ -220,7 +215,7 @@ func TestMeasureOverviewSize(t *testing.T) {
|
||||
t.Fatalf("expected positive size, got %d", size)
|
||||
}
|
||||
|
||||
// Ensure snapshot stored
|
||||
// Ensure snapshot stored.
|
||||
cached, err := loadStoredOverviewSize(target)
|
||||
if err != nil {
|
||||
t.Fatalf("loadStoredOverviewSize: %v", err)
|
||||
@@ -279,13 +274,13 @@ func TestLoadCacheExpiresWhenDirectoryChanges(t *testing.T) {
|
||||
t.Fatalf("saveCacheToDisk: %v", err)
|
||||
}
|
||||
|
||||
// Touch directory to advance mtime beyond grace period.
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Force modtime difference beyond grace window by simulating an older cache entry.
|
||||
// Simulate older cache entry to exceed grace window.
|
||||
cachePath, err := getCachePath(target)
|
||||
if err != nil {
|
||||
t.Fatalf("getCachePath: %v", err)
|
||||
@@ -335,24 +330,24 @@ func TestScanPathPermissionError(t *testing.T) {
|
||||
t.Fatalf("create locked dir: %v", err)
|
||||
}
|
||||
|
||||
// Create a file inside before locking, just to be sure
|
||||
// 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
|
||||
// Remove permissions.
|
||||
if err := os.Chmod(lockedDir, 0o000); err != nil {
|
||||
t.Fatalf("chmod 000: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
// Restore permissions so cleanup can work
|
||||
// Restore permissions for cleanup.
|
||||
_ = os.Chmod(lockedDir, 0o755)
|
||||
}()
|
||||
|
||||
var files, dirs, bytes int64
|
||||
current := ""
|
||||
|
||||
// Scanning the locked dir itself should fail
|
||||
// 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")
|
||||
|
||||
@@ -222,7 +222,7 @@ func loadCacheFromDisk(path string) (*cacheEntry, error) {
|
||||
}
|
||||
|
||||
if info.ModTime().After(entry.ModTime) {
|
||||
// Only expire cache if the directory has been newer for longer than the grace window.
|
||||
// Allow grace window.
|
||||
if cacheModTimeGrace <= 0 || info.ModTime().Sub(entry.ModTime) > cacheModTimeGrace {
|
||||
return nil, fmt.Errorf("cache expired: directory modified")
|
||||
}
|
||||
@@ -290,29 +290,23 @@ func removeOverviewSnapshot(path string) {
|
||||
}
|
||||
}
|
||||
|
||||
// prefetchOverviewCache scans overview directories in background
|
||||
// to populate cache for faster overview mode access
|
||||
// prefetchOverviewCache warms overview cache in background.
|
||||
func prefetchOverviewCache(ctx context.Context) {
|
||||
entries := createOverviewEntries()
|
||||
|
||||
// Check which entries need refresh
|
||||
var needScan []string
|
||||
for _, entry := range entries {
|
||||
// Skip if we have fresh cache
|
||||
if size, err := loadStoredOverviewSize(entry.Path); err == nil && size > 0 {
|
||||
continue
|
||||
}
|
||||
needScan = append(needScan, entry.Path)
|
||||
}
|
||||
|
||||
// Nothing to scan
|
||||
if len(needScan) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Scan and cache in background with context cancellation support
|
||||
for _, path := range needScan {
|
||||
// Check if context is cancelled
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
|
||||
@@ -5,23 +5,20 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// isCleanableDir checks if a directory is safe to manually delete
|
||||
// but NOT cleaned by mo clean (so user might want to delete it manually)
|
||||
// isCleanableDir marks paths safe to delete manually (not handled by mo clean).
|
||||
func isCleanableDir(path string) bool {
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Exclude paths that mo clean will handle automatically
|
||||
// These are system caches/logs that mo clean already processes
|
||||
// Exclude paths mo clean already handles.
|
||||
if isHandledByMoClean(path) {
|
||||
return false
|
||||
}
|
||||
|
||||
baseName := filepath.Base(path)
|
||||
|
||||
// Only mark project dependencies and build outputs
|
||||
// These are safe to delete but mo clean won't touch them
|
||||
// Project dependencies and build outputs are safe.
|
||||
if projectDependencyDirs[baseName] {
|
||||
return true
|
||||
}
|
||||
@@ -29,9 +26,8 @@ func isCleanableDir(path string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// isHandledByMoClean checks if this path will be cleaned by mo clean
|
||||
// isHandledByMoClean checks if a path is cleaned by mo clean.
|
||||
func isHandledByMoClean(path string) bool {
|
||||
// Paths that mo clean handles (from clean.sh)
|
||||
cleanPaths := []string{
|
||||
"/Library/Caches/",
|
||||
"/Library/Logs/",
|
||||
@@ -49,16 +45,15 @@ func isHandledByMoClean(path string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Project dependency and build directories
|
||||
// These are safe to delete manually but mo clean won't touch them
|
||||
// Project dependency and build directories.
|
||||
var projectDependencyDirs = map[string]bool{
|
||||
// JavaScript/Node dependencies
|
||||
"node_modules": true,
|
||||
// JavaScript/Node.
|
||||
"node_modules": true,
|
||||
"bower_components": true,
|
||||
".yarn": true, // Yarn local cache
|
||||
".pnpm-store": true, // pnpm store
|
||||
".yarn": true,
|
||||
".pnpm-store": true,
|
||||
|
||||
// Python dependencies and outputs
|
||||
// Python.
|
||||
"venv": true,
|
||||
".venv": true,
|
||||
"virtualenv": true,
|
||||
@@ -68,18 +63,18 @@ var projectDependencyDirs = map[string]bool{
|
||||
".ruff_cache": true,
|
||||
".tox": true,
|
||||
".eggs": true,
|
||||
"htmlcov": true, // Coverage reports
|
||||
".ipynb_checkpoints": true, // Jupyter checkpoints
|
||||
"htmlcov": true,
|
||||
".ipynb_checkpoints": true,
|
||||
|
||||
// Ruby dependencies
|
||||
// Ruby.
|
||||
"vendor": true,
|
||||
".bundle": true,
|
||||
|
||||
// Java/Kotlin/Scala
|
||||
".gradle": true, // Project-level Gradle cache
|
||||
"out": true, // IntelliJ IDEA build output
|
||||
// Java/Kotlin/Scala.
|
||||
".gradle": true,
|
||||
"out": true,
|
||||
|
||||
// Build outputs (can be rebuilt)
|
||||
// Build outputs.
|
||||
"build": true,
|
||||
"dist": true,
|
||||
"target": true,
|
||||
@@ -88,25 +83,25 @@ var projectDependencyDirs = map[string]bool{
|
||||
".output": true,
|
||||
".parcel-cache": true,
|
||||
".turbo": true,
|
||||
".vite": true, // Vite cache
|
||||
".nx": true, // Nx cache
|
||||
".vite": true,
|
||||
".nx": true,
|
||||
"coverage": true,
|
||||
".coverage": true,
|
||||
".nyc_output": true, // NYC coverage
|
||||
".nyc_output": true,
|
||||
|
||||
// Frontend framework outputs
|
||||
".angular": true, // Angular CLI cache
|
||||
".svelte-kit": true, // SvelteKit build
|
||||
".astro": true, // Astro cache
|
||||
".docusaurus": true, // Docusaurus build
|
||||
// Frontend framework outputs.
|
||||
".angular": true,
|
||||
".svelte-kit": true,
|
||||
".astro": true,
|
||||
".docusaurus": true,
|
||||
|
||||
// iOS/macOS development
|
||||
// Apple dev.
|
||||
"DerivedData": true,
|
||||
"Pods": true,
|
||||
".build": true,
|
||||
"Carthage": true,
|
||||
".dart_tool": true,
|
||||
|
||||
// Other tools
|
||||
".terraform": true, // Terraform plugins
|
||||
// Other tools.
|
||||
".terraform": true,
|
||||
}
|
||||
|
||||
@@ -6,35 +6,35 @@ const (
|
||||
maxEntries = 30
|
||||
maxLargeFiles = 30
|
||||
barWidth = 24
|
||||
minLargeFileSize = 100 << 20 // 100 MB
|
||||
defaultViewport = 12 // Default viewport when terminal height is unknown
|
||||
overviewCacheTTL = 7 * 24 * time.Hour // 7 days
|
||||
minLargeFileSize = 100 << 20
|
||||
defaultViewport = 12
|
||||
overviewCacheTTL = 7 * 24 * time.Hour
|
||||
overviewCacheFile = "overview_sizes.json"
|
||||
duTimeout = 30 * time.Second // Fail faster to fallback to concurrent scan
|
||||
duTimeout = 30 * time.Second
|
||||
mdlsTimeout = 5 * time.Second
|
||||
maxConcurrentOverview = 8 // Increased parallel overview scans
|
||||
batchUpdateSize = 100 // Batch atomic updates every N items
|
||||
cacheModTimeGrace = 30 * time.Minute // Ignore minor directory mtime bumps
|
||||
maxConcurrentOverview = 8
|
||||
batchUpdateSize = 100
|
||||
cacheModTimeGrace = 30 * time.Minute
|
||||
|
||||
// Worker pool configuration
|
||||
minWorkers = 16 // Safe baseline for older machines
|
||||
maxWorkers = 64 // Cap at 64 to avoid OS resource contention
|
||||
cpuMultiplier = 4 // Balanced CPU usage
|
||||
maxDirWorkers = 32 // Limit concurrent subdirectory scans
|
||||
openCommandTimeout = 10 * time.Second // Timeout for open/reveal commands
|
||||
// Worker pool limits.
|
||||
minWorkers = 16
|
||||
maxWorkers = 64
|
||||
cpuMultiplier = 4
|
||||
maxDirWorkers = 32
|
||||
openCommandTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
var foldDirs = map[string]bool{
|
||||
// Version control
|
||||
// VCS.
|
||||
".git": true,
|
||||
".svn": true,
|
||||
".hg": true,
|
||||
|
||||
// JavaScript/Node
|
||||
// JavaScript/Node.
|
||||
"node_modules": true,
|
||||
".npm": true,
|
||||
"_npx": true, // ~/.npm/_npx global cache
|
||||
"_cacache": true, // ~/.npm/_cacache
|
||||
"_npx": true,
|
||||
"_cacache": true,
|
||||
"_logs": true,
|
||||
"_locks": true,
|
||||
"_quick": true,
|
||||
@@ -56,7 +56,7 @@ var foldDirs = map[string]bool{
|
||||
".bun": true,
|
||||
".deno": true,
|
||||
|
||||
// Python
|
||||
// Python.
|
||||
"__pycache__": true,
|
||||
".pytest_cache": true,
|
||||
".mypy_cache": true,
|
||||
@@ -73,7 +73,7 @@ var foldDirs = map[string]bool{
|
||||
".pip": true,
|
||||
".pipx": true,
|
||||
|
||||
// Ruby/Go/PHP (vendor), Java/Kotlin/Scala/Rust (target)
|
||||
// Ruby/Go/PHP (vendor), Java/Kotlin/Scala/Rust (target).
|
||||
"vendor": true,
|
||||
".bundle": true,
|
||||
"gems": true,
|
||||
@@ -88,20 +88,20 @@ var foldDirs = map[string]bool{
|
||||
".composer": true,
|
||||
".cargo": true,
|
||||
|
||||
// Build outputs
|
||||
// Build outputs.
|
||||
"build": true,
|
||||
"dist": true,
|
||||
".output": true,
|
||||
"coverage": true,
|
||||
".coverage": true,
|
||||
|
||||
// IDE
|
||||
// IDE.
|
||||
".idea": true,
|
||||
".vscode": true,
|
||||
".vs": true,
|
||||
".fleet": true,
|
||||
|
||||
// Cache directories
|
||||
// Cache directories.
|
||||
".cache": true,
|
||||
"__MACOSX": true,
|
||||
".DS_Store": true,
|
||||
@@ -121,18 +121,18 @@ var foldDirs = map[string]bool{
|
||||
".sdkman": true,
|
||||
".nvm": true,
|
||||
|
||||
// macOS specific
|
||||
// macOS.
|
||||
"Application Scripts": true,
|
||||
"Saved Application State": true,
|
||||
|
||||
// iCloud
|
||||
// iCloud.
|
||||
"Mobile Documents": true,
|
||||
|
||||
// Docker & Containers
|
||||
// Containers.
|
||||
".docker": true,
|
||||
".containerd": true,
|
||||
|
||||
// Mobile development
|
||||
// Mobile development.
|
||||
"Pods": true,
|
||||
"DerivedData": true,
|
||||
".build": true,
|
||||
@@ -140,18 +140,18 @@ var foldDirs = map[string]bool{
|
||||
"Carthage": true,
|
||||
".dart_tool": true,
|
||||
|
||||
// Web frameworks
|
||||
// Web frameworks.
|
||||
".angular": true,
|
||||
".svelte-kit": true,
|
||||
".astro": true,
|
||||
".solid": true,
|
||||
|
||||
// Databases
|
||||
// Databases.
|
||||
".mysql": true,
|
||||
".postgres": true,
|
||||
"mongodb": true,
|
||||
|
||||
// Other
|
||||
// Other.
|
||||
".terraform": true,
|
||||
".vagrant": true,
|
||||
"tmp": true,
|
||||
@@ -170,22 +170,22 @@ var skipSystemDirs = map[string]bool{
|
||||
"bin": true,
|
||||
"etc": true,
|
||||
"var": true,
|
||||
"opt": false, // User might want to specific check opt
|
||||
"usr": false, // User might check usr
|
||||
"Volumes": true, // Skip external drives by default when scanning root
|
||||
"Network": true, // Skip network mounts
|
||||
"opt": false,
|
||||
"usr": false,
|
||||
"Volumes": true,
|
||||
"Network": true,
|
||||
".vol": true,
|
||||
".Spotlight-V100": true,
|
||||
".fseventsd": true,
|
||||
".DocumentRevisions-V100": true,
|
||||
".TemporaryItems": true,
|
||||
".MobileBackups": true, // Time Machine local snapshots
|
||||
".MobileBackups": true,
|
||||
}
|
||||
|
||||
var defaultSkipDirs = map[string]bool{
|
||||
"nfs": true, // Network File System
|
||||
"PHD": true, // Parallels Shared Folders / Home Directories
|
||||
"Permissions": true, // Common macOS deny folder
|
||||
"nfs": true,
|
||||
"PHD": true,
|
||||
"Permissions": true,
|
||||
}
|
||||
|
||||
var skipExtensions = map[string]bool{
|
||||
|
||||
@@ -23,13 +23,13 @@ func deletePathCmd(path string, counter *int64) tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
// deleteMultiplePathsCmd deletes multiple paths and returns combined results
|
||||
// deleteMultiplePathsCmd deletes paths and aggregates results.
|
||||
func deleteMultiplePathsCmd(paths []string, counter *int64) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
var totalCount int64
|
||||
var errors []string
|
||||
|
||||
// Delete deeper paths first to avoid parent removal triggering child not-exist errors
|
||||
// Delete 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))
|
||||
@@ -40,7 +40,7 @@ func deleteMultiplePathsCmd(paths []string, counter *int64) tea.Cmd {
|
||||
totalCount += count
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue // Parent already removed - not an actionable error
|
||||
continue
|
||||
}
|
||||
errors = append(errors, err.Error())
|
||||
}
|
||||
@@ -51,17 +51,16 @@ func deleteMultiplePathsCmd(paths []string, counter *int64) tea.Cmd {
|
||||
resultErr = &multiDeleteError{errors: errors}
|
||||
}
|
||||
|
||||
// Return empty path to trigger full refresh since multiple items were deleted
|
||||
return deleteProgressMsg{
|
||||
done: true,
|
||||
err: resultErr,
|
||||
count: totalCount,
|
||||
path: "", // Empty path signals multiple deletions
|
||||
path: "",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// multiDeleteError holds multiple deletion errors
|
||||
// multiDeleteError holds multiple deletion errors.
|
||||
type multiDeleteError struct {
|
||||
errors []string
|
||||
}
|
||||
@@ -79,14 +78,13 @@ func deletePathWithProgress(root string, counter *int64) (int64, error) {
|
||||
|
||||
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
// Skip permission errors but continue walking
|
||||
// Skip permission errors but continue.
|
||||
if os.IsPermission(err) {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
return filepath.SkipDir
|
||||
}
|
||||
// For other errors, record and continue
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
@@ -100,7 +98,6 @@ func deletePathWithProgress(root string, counter *int64) (int64, error) {
|
||||
atomic.StoreInt64(counter, count)
|
||||
}
|
||||
} else if firstErr == nil {
|
||||
// Record first deletion error
|
||||
firstErr = removeErr
|
||||
}
|
||||
}
|
||||
@@ -108,19 +105,15 @@ func deletePathWithProgress(root string, counter *int64) (int64, error) {
|
||||
return nil
|
||||
})
|
||||
|
||||
// Track walk error separately
|
||||
if err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
|
||||
// Try to remove remaining directory structure
|
||||
// Even if this fails, we still report files deleted
|
||||
if removeErr := os.RemoveAll(root); removeErr != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = removeErr
|
||||
}
|
||||
}
|
||||
|
||||
// Always return count (even if there were errors), along with first error
|
||||
return count, firstErr
|
||||
}
|
||||
|
||||
@@ -11,9 +11,7 @@ func TestDeleteMultiplePathsCmdHandlesParentChild(t *testing.T) {
|
||||
parent := filepath.Join(base, "parent")
|
||||
child := filepath.Join(parent, "child")
|
||||
|
||||
// Create structure:
|
||||
// parent/fileA
|
||||
// parent/child/fileC
|
||||
// Structure: parent/fileA, parent/child/fileC.
|
||||
if err := os.MkdirAll(child, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ func displayPath(path string) string {
|
||||
return path
|
||||
}
|
||||
|
||||
// truncateMiddle truncates string in the middle, keeping head and tail.
|
||||
// truncateMiddle trims the middle, keeping head and tail.
|
||||
func truncateMiddle(s string, maxWidth int) string {
|
||||
runes := []rune(s)
|
||||
currentWidth := displayWidth(s)
|
||||
@@ -27,9 +27,7 @@ func truncateMiddle(s string, maxWidth int) string {
|
||||
return s
|
||||
}
|
||||
|
||||
// Reserve 3 width for "..."
|
||||
if maxWidth < 10 {
|
||||
// Simple truncation for very small width
|
||||
width := 0
|
||||
for i, r := range runes {
|
||||
width += runeWidth(r)
|
||||
@@ -40,11 +38,9 @@ func truncateMiddle(s string, maxWidth int) string {
|
||||
return s
|
||||
}
|
||||
|
||||
// Keep more of the tail (filename usually more important)
|
||||
targetHeadWidth := (maxWidth - 3) / 3
|
||||
targetTailWidth := maxWidth - 3 - targetHeadWidth
|
||||
|
||||
// Find head cutoff point based on display width
|
||||
headWidth := 0
|
||||
headIdx := 0
|
||||
for i, r := range runes {
|
||||
@@ -56,7 +52,6 @@ func truncateMiddle(s string, maxWidth int) string {
|
||||
headIdx = i + 1
|
||||
}
|
||||
|
||||
// Find tail cutoff point
|
||||
tailWidth := 0
|
||||
tailIdx := len(runes)
|
||||
for i := len(runes) - 1; i >= 0; i-- {
|
||||
@@ -108,7 +103,6 @@ func coloredProgressBar(value, max int64, percent float64) string {
|
||||
filled = barWidth
|
||||
}
|
||||
|
||||
// Choose color based on percentage
|
||||
var barColor string
|
||||
if percent >= 50 {
|
||||
barColor = colorRed
|
||||
@@ -142,7 +136,7 @@ func coloredProgressBar(value, max int64, percent float64) string {
|
||||
return bar + colorReset
|
||||
}
|
||||
|
||||
// Calculate display width considering CJK characters and Emoji.
|
||||
// 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
|
||||
@@ -173,18 +167,16 @@ func displayWidth(s string) int {
|
||||
return width
|
||||
}
|
||||
|
||||
// calculateNameWidth computes the optimal name column width based on terminal width.
|
||||
// Fixed elements: prefix(3) + num(3) + bar(24) + percent(7) + sep(5) + icon(3) + size(12) + hint(4) = 61
|
||||
// calculateNameWidth computes name column width from terminal width.
|
||||
func calculateNameWidth(termWidth int) int {
|
||||
const fixedWidth = 61
|
||||
available := termWidth - fixedWidth
|
||||
|
||||
// Constrain to reasonable bounds
|
||||
if available < 24 {
|
||||
return 24 // Minimum for readability
|
||||
return 24
|
||||
}
|
||||
if available > 60 {
|
||||
return 60 // Maximum to avoid overly wide columns
|
||||
return 60
|
||||
}
|
||||
return available
|
||||
}
|
||||
@@ -233,7 +225,7 @@ func padName(name string, targetWidth int) string {
|
||||
return name + strings.Repeat(" ", targetWidth-currentWidth)
|
||||
}
|
||||
|
||||
// formatUnusedTime formats the time since last access in a compact way.
|
||||
// formatUnusedTime formats time since last access.
|
||||
func formatUnusedTime(lastAccess time.Time) string {
|
||||
if lastAccess.IsZero() {
|
||||
return ""
|
||||
|
||||
@@ -168,7 +168,6 @@ func TestTruncateMiddle(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDisplayPath(t *testing.T) {
|
||||
// This test assumes HOME is set
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func() string
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
package main
|
||||
|
||||
// entryHeap implements heap.Interface for a min-heap of dirEntry (sorted by Size)
|
||||
// Since we want Top N Largest, we use a Min Heap of size N.
|
||||
// When adding a new item:
|
||||
// 1. If heap size < N: push
|
||||
// 2. If heap size == N and item > min (root): pop min, push item
|
||||
// The heap will thus maintain the largest N items.
|
||||
// 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 } // Min-heap based on Size
|
||||
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{}) {
|
||||
@@ -24,7 +19,7 @@ func (h *entryHeap) Pop() interface{} {
|
||||
return x
|
||||
}
|
||||
|
||||
// largeFileHeap implements heap.Interface for fileEntry
|
||||
// largeFileHeap is a min-heap for fileEntry.
|
||||
type largeFileHeap []fileEntry
|
||||
|
||||
func (h largeFileHeap) Len() int { return len(h) }
|
||||
|
||||
@@ -130,7 +130,6 @@ func main() {
|
||||
var isOverview bool
|
||||
|
||||
if target == "" {
|
||||
// Default to overview mode
|
||||
isOverview = true
|
||||
abs = "/"
|
||||
} else {
|
||||
@@ -143,8 +142,7 @@ func main() {
|
||||
isOverview = false
|
||||
}
|
||||
|
||||
// Prefetch overview cache in background (non-blocking)
|
||||
// Use context with timeout to prevent hanging
|
||||
// Warm overview cache in background.
|
||||
prefetchCtx, prefetchCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer prefetchCancel()
|
||||
go prefetchOverviewCache(prefetchCtx)
|
||||
@@ -184,7 +182,6 @@ func newModel(path string, isOverview bool) model {
|
||||
largeMultiSelected: make(map[string]bool),
|
||||
}
|
||||
|
||||
// In overview mode, create shortcut entries
|
||||
if isOverview {
|
||||
m.scanning = false
|
||||
m.hydrateOverviewEntries()
|
||||
@@ -205,12 +202,10 @@ func createOverviewEntries() []dirEntry {
|
||||
home := os.Getenv("HOME")
|
||||
entries := []dirEntry{}
|
||||
|
||||
// Separate Home and ~/Library for better visibility and performance
|
||||
// Home excludes Library to avoid duplicate scanning
|
||||
// Separate Home and ~/Library to avoid double counting.
|
||||
if home != "" {
|
||||
entries = append(entries, dirEntry{Name: "Home", Path: home, IsDir: true, Size: -1})
|
||||
|
||||
// Add ~/Library separately so users can see app data usage
|
||||
userLibrary := filepath.Join(home, "Library")
|
||||
if _, err := os.Stat(userLibrary); err == nil {
|
||||
entries = append(entries, dirEntry{Name: "App Library", Path: userLibrary, IsDir: true, Size: -1})
|
||||
@@ -222,7 +217,7 @@ func createOverviewEntries() []dirEntry {
|
||||
dirEntry{Name: "System Library", Path: "/Library", IsDir: true, Size: -1},
|
||||
)
|
||||
|
||||
// Add Volumes shortcut only when it contains real mounted folders (e.g., external disks)
|
||||
// Include Volumes only when real mounts exist.
|
||||
if hasUsefulVolumeMounts("/Volumes") {
|
||||
entries = append(entries, dirEntry{Name: "Volumes", Path: "/Volumes", IsDir: true, Size: -1})
|
||||
}
|
||||
@@ -238,7 +233,6 @@ func hasUsefulVolumeMounts(path string) bool {
|
||||
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
// Skip hidden control entries for Spotlight/TimeMachine etc.
|
||||
if strings.HasPrefix(name, ".") {
|
||||
continue
|
||||
}
|
||||
@@ -276,8 +270,7 @@ func (m *model) hydrateOverviewEntries() {
|
||||
}
|
||||
|
||||
func (m *model) sortOverviewEntriesBySize() {
|
||||
// Sort entries by size (largest first)
|
||||
// Use stable sort to maintain order when sizes are equal
|
||||
// Stable sort by size.
|
||||
sort.SliceStable(m.entries, func(i, j int) bool {
|
||||
return m.entries[i].Size > m.entries[j].Size
|
||||
})
|
||||
@@ -288,7 +281,6 @@ func (m *model) scheduleOverviewScans() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Find pending entries (not scanned and not currently scanning)
|
||||
var pendingIndices []int
|
||||
for i, entry := range m.entries {
|
||||
if entry.Size < 0 && !m.overviewScanningSet[entry.Path] {
|
||||
@@ -299,18 +291,15 @@ func (m *model) scheduleOverviewScans() tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
// No more work to do
|
||||
if len(pendingIndices) == 0 {
|
||||
m.overviewScanning = false
|
||||
if !hasPendingOverviewEntries(m.entries) {
|
||||
// All scans complete - sort entries by size (largest first)
|
||||
m.sortOverviewEntriesBySize()
|
||||
m.status = "Ready"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Mark all as scanning
|
||||
var cmds []tea.Cmd
|
||||
for _, idx := range pendingIndices {
|
||||
entry := m.entries[idx]
|
||||
@@ -361,7 +350,6 @@ func (m model) Init() tea.Cmd {
|
||||
|
||||
func (m model) scanCmd(path string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
// Try to load from persistent cache first
|
||||
if cached, err := loadCacheFromDisk(path); err == nil {
|
||||
result := scanResult{
|
||||
Entries: cached.Entries,
|
||||
@@ -371,8 +359,6 @@ func (m model) scanCmd(path string) tea.Cmd {
|
||||
return scanResultMsg{result: result, err: nil}
|
||||
}
|
||||
|
||||
// Use singleflight to avoid duplicate scans of the same path
|
||||
// If multiple goroutines request the same path, only one scan will be performed
|
||||
v, err, _ := scanGroup.Do(path, func() (interface{}, error) {
|
||||
return scanPathConcurrent(path, m.filesScanned, m.dirsScanned, m.bytesScanned, m.currentPath)
|
||||
})
|
||||
@@ -383,10 +369,8 @@ func (m model) scanCmd(path string) tea.Cmd {
|
||||
|
||||
result := v.(scanResult)
|
||||
|
||||
// Save to persistent cache asynchronously with error logging
|
||||
go func(p string, r scanResult) {
|
||||
if err := saveCacheToDisk(p, r); err != nil {
|
||||
// Log error but don't fail the scan
|
||||
_ = err // Cache save failure is not critical
|
||||
}
|
||||
}(path, result)
|
||||
@@ -412,7 +396,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case deleteProgressMsg:
|
||||
if msg.done {
|
||||
m.deleting = false
|
||||
// Clear multi-selection after delete
|
||||
m.multiSelected = make(map[string]bool)
|
||||
m.largeMultiSelected = make(map[string]bool)
|
||||
if msg.err != nil {
|
||||
@@ -424,7 +407,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
invalidateCache(m.path)
|
||||
m.status = fmt.Sprintf("Deleted %d items", msg.count)
|
||||
// Mark all caches as dirty
|
||||
for i := range m.history {
|
||||
m.history[i].Dirty = true
|
||||
}
|
||||
@@ -433,9 +415,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
entry.Dirty = true
|
||||
m.cache[path] = entry
|
||||
}
|
||||
// Refresh the view
|
||||
m.scanning = true
|
||||
// Reset scan counters for rescan
|
||||
atomic.StoreInt64(m.filesScanned, 0)
|
||||
atomic.StoreInt64(m.dirsScanned, 0)
|
||||
atomic.StoreInt64(m.bytesScanned, 0)
|
||||
@@ -452,7 +432,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.status = fmt.Sprintf("Scan failed: %v", msg.err)
|
||||
return m, nil
|
||||
}
|
||||
// Filter out 0-byte items for cleaner view
|
||||
filteredEntries := make([]dirEntry, 0, len(msg.result.Entries))
|
||||
for _, e := range msg.result.Entries {
|
||||
if e.Size > 0 {
|
||||
@@ -477,7 +456,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
case overviewSizeMsg:
|
||||
// Remove from scanning set
|
||||
delete(m.overviewScanningSet, msg.Path)
|
||||
|
||||
if msg.Err == nil {
|
||||
@@ -488,7 +466,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
if m.inOverviewMode() {
|
||||
// Update entry with result
|
||||
for i := range m.entries {
|
||||
if m.entries[i].Path == msg.Path {
|
||||
if msg.Err == nil {
|
||||
@@ -501,18 +478,15 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
m.totalSize = sumKnownEntrySizes(m.entries)
|
||||
|
||||
// Show error briefly if any
|
||||
if msg.Err != nil {
|
||||
m.status = fmt.Sprintf("Unable to measure %s: %v", displayPath(msg.Path), msg.Err)
|
||||
}
|
||||
|
||||
// Schedule next batch of scans
|
||||
cmd := m.scheduleOverviewScans()
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
case tickMsg:
|
||||
// Keep spinner running if scanning or deleting or if there are pending overview items
|
||||
hasPending := false
|
||||
if m.inOverviewMode() {
|
||||
for _, entry := range m.entries {
|
||||
@@ -524,7 +498,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
if m.scanning || m.deleting || (m.inOverviewMode() && (m.overviewScanning || hasPending)) {
|
||||
m.spinner = (m.spinner + 1) % len(spinnerFrames)
|
||||
// Update delete progress status
|
||||
if m.deleting && m.deleteCount != nil {
|
||||
count := atomic.LoadInt64(m.deleteCount)
|
||||
if count > 0 {
|
||||
@@ -540,18 +513,16 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
// Handle delete confirmation
|
||||
// Delete confirm flow.
|
||||
if m.deleteConfirm {
|
||||
switch msg.String() {
|
||||
case "delete", "backspace":
|
||||
// Confirm delete - start async deletion
|
||||
m.deleteConfirm = false
|
||||
m.deleting = true
|
||||
var deleteCount int64
|
||||
m.deleteCount = &deleteCount
|
||||
|
||||
// Collect paths to delete (multi-select or single)
|
||||
// Using paths instead of indices is safer - avoids deleting wrong files if list changes
|
||||
// Collect paths (safer than indices).
|
||||
var pathsToDelete []string
|
||||
if m.showLargeFiles {
|
||||
if len(m.largeMultiSelected) > 0 {
|
||||
@@ -587,13 +558,11 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.status = fmt.Sprintf("Deleting %d items...", len(pathsToDelete))
|
||||
return m, tea.Batch(deleteMultiplePathsCmd(pathsToDelete, m.deleteCount), tickCmd())
|
||||
case "esc", "q":
|
||||
// Cancel delete with ESC or Q
|
||||
m.status = "Cancelled"
|
||||
m.deleteConfirm = false
|
||||
m.deleteTarget = nil
|
||||
return m, nil
|
||||
default:
|
||||
// Ignore other keys - keep showing confirmation
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
@@ -648,7 +617,6 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
if len(m.history) == 0 {
|
||||
// Return to overview if at top level
|
||||
if !m.inOverviewMode() {
|
||||
return m, m.switchToOverviewMode()
|
||||
}
|
||||
@@ -663,7 +631,7 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.largeOffset = last.LargeOffset
|
||||
m.isOverview = last.IsOverview
|
||||
if last.Dirty {
|
||||
// If returning to overview mode, refresh overview entries instead of scanning
|
||||
// On overview return, refresh cached entries.
|
||||
if last.IsOverview {
|
||||
m.hydrateOverviewEntries()
|
||||
m.totalSize = sumKnownEntrySizes(m.entries)
|
||||
@@ -696,17 +664,14 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.scanning = false
|
||||
return m, nil
|
||||
case "r":
|
||||
// Clear multi-selection on refresh
|
||||
m.multiSelected = make(map[string]bool)
|
||||
m.largeMultiSelected = make(map[string]bool)
|
||||
|
||||
if m.inOverviewMode() {
|
||||
// In overview mode, clear cache and re-scan known entries
|
||||
m.overviewSizeCache = make(map[string]int64)
|
||||
m.overviewScanningSet = make(map[string]bool)
|
||||
m.hydrateOverviewEntries() // Reset sizes to pending
|
||||
|
||||
// Reset all entries to pending state for visual feedback
|
||||
for i := range m.entries {
|
||||
m.entries[i].Size = -1
|
||||
}
|
||||
@@ -717,11 +682,9 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
return m, tea.Batch(m.scheduleOverviewScans(), tickCmd())
|
||||
}
|
||||
|
||||
// Normal mode: Invalidate cache before rescanning
|
||||
invalidateCache(m.path)
|
||||
m.status = "Refreshing..."
|
||||
m.scanning = true
|
||||
// Reset scan counters for refresh
|
||||
atomic.StoreInt64(m.filesScanned, 0)
|
||||
atomic.StoreInt64(m.dirsScanned, 0)
|
||||
atomic.StoreInt64(m.bytesScanned, 0)
|
||||
@@ -730,7 +693,6 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, tea.Batch(m.scanCmd(m.path), tickCmd())
|
||||
case "t", "T":
|
||||
// Don't allow switching to large files view in overview mode
|
||||
if !m.inOverviewMode() {
|
||||
m.showLargeFiles = !m.showLargeFiles
|
||||
if m.showLargeFiles {
|
||||
@@ -740,16 +702,13 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
} else {
|
||||
m.multiSelected = make(map[string]bool)
|
||||
}
|
||||
// Reset status when switching views
|
||||
m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize))
|
||||
}
|
||||
case "o":
|
||||
// Open selected entries (multi-select aware)
|
||||
// Limit batch operations to prevent system resource exhaustion
|
||||
// Open selected entries (multi-select aware).
|
||||
const maxBatchOpen = 20
|
||||
if m.showLargeFiles {
|
||||
if len(m.largeFiles) > 0 {
|
||||
// Check for multi-selection first
|
||||
if len(m.largeMultiSelected) > 0 {
|
||||
count := len(m.largeMultiSelected)
|
||||
if count > maxBatchOpen {
|
||||
@@ -775,7 +734,6 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
} else if len(m.entries) > 0 {
|
||||
// Check for multi-selection first
|
||||
if len(m.multiSelected) > 0 {
|
||||
count := len(m.multiSelected)
|
||||
if count > maxBatchOpen {
|
||||
@@ -801,12 +759,10 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
case "f", "F":
|
||||
// Reveal selected entries in Finder (multi-select aware)
|
||||
// Limit batch operations to prevent system resource exhaustion
|
||||
// Reveal in Finder (multi-select aware).
|
||||
const maxBatchReveal = 20
|
||||
if m.showLargeFiles {
|
||||
if len(m.largeFiles) > 0 {
|
||||
// Check for multi-selection first
|
||||
if len(m.largeMultiSelected) > 0 {
|
||||
count := len(m.largeMultiSelected)
|
||||
if count > maxBatchReveal {
|
||||
@@ -832,7 +788,6 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
} else if len(m.entries) > 0 {
|
||||
// Check for multi-selection first
|
||||
if len(m.multiSelected) > 0 {
|
||||
count := len(m.multiSelected)
|
||||
if count > maxBatchReveal {
|
||||
@@ -858,8 +813,7 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
case " ":
|
||||
// Toggle multi-select with spacebar
|
||||
// Using paths as keys (instead of indices) is safer and more maintainable
|
||||
// Toggle multi-select (paths as keys).
|
||||
if m.showLargeFiles {
|
||||
if len(m.largeFiles) > 0 && m.largeSelected < len(m.largeFiles) {
|
||||
if m.largeMultiSelected == nil {
|
||||
@@ -871,11 +825,9 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
} else {
|
||||
m.largeMultiSelected[selectedPath] = true
|
||||
}
|
||||
// Update status to show selection count and total size
|
||||
count := len(m.largeMultiSelected)
|
||||
if count > 0 {
|
||||
var totalSize int64
|
||||
// Calculate total size by looking up each selected path
|
||||
for path := range m.largeMultiSelected {
|
||||
for _, file := range m.largeFiles {
|
||||
if file.Path == path {
|
||||
@@ -899,11 +851,9 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
} else {
|
||||
m.multiSelected[selectedPath] = true
|
||||
}
|
||||
// Update status to show selection count and total size
|
||||
count := len(m.multiSelected)
|
||||
if count > 0 {
|
||||
var totalSize int64
|
||||
// Calculate total size by looking up each selected path
|
||||
for path := range m.multiSelected {
|
||||
for _, entry := range m.entries {
|
||||
if entry.Path == path {
|
||||
@@ -918,15 +868,11 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
case "delete", "backspace":
|
||||
// Delete selected file(s) or directory(ies)
|
||||
if m.showLargeFiles {
|
||||
if len(m.largeFiles) > 0 {
|
||||
// Check for multi-selection first
|
||||
if len(m.largeMultiSelected) > 0 {
|
||||
m.deleteConfirm = true
|
||||
// Set deleteTarget to first selected for display purposes
|
||||
for path := range m.largeMultiSelected {
|
||||
// Find the file entry by path
|
||||
for _, file := range m.largeFiles {
|
||||
if file.Path == path {
|
||||
m.deleteTarget = &dirEntry{
|
||||
@@ -952,12 +898,10 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
} else if len(m.entries) > 0 && !m.inOverviewMode() {
|
||||
// Check for multi-selection first
|
||||
if len(m.multiSelected) > 0 {
|
||||
m.deleteConfirm = true
|
||||
// Set deleteTarget to first selected for display purposes
|
||||
for path := range m.multiSelected {
|
||||
// Find the entry by path
|
||||
// Resolve entry by path.
|
||||
for i := range m.entries {
|
||||
if m.entries[i].Path == path {
|
||||
m.deleteTarget = &m.entries[i]
|
||||
@@ -994,7 +938,6 @@ func (m *model) switchToOverviewMode() tea.Cmd {
|
||||
m.status = "Ready"
|
||||
return nil
|
||||
}
|
||||
// Start tick to animate spinner while scanning
|
||||
return tea.Batch(cmd, tickCmd())
|
||||
}
|
||||
|
||||
@@ -1004,7 +947,6 @@ func (m model) enterSelectedDir() (tea.Model, tea.Cmd) {
|
||||
}
|
||||
selected := m.entries[m.selected]
|
||||
if selected.IsDir {
|
||||
// Always save current state to history (including overview mode)
|
||||
m.history = append(m.history, snapshotFromModel(m))
|
||||
m.path = selected.Path
|
||||
m.selected = 0
|
||||
@@ -1012,11 +954,9 @@ func (m model) enterSelectedDir() (tea.Model, tea.Cmd) {
|
||||
m.status = "Scanning..."
|
||||
m.scanning = true
|
||||
m.isOverview = false
|
||||
// Clear multi-selection when entering new directory
|
||||
m.multiSelected = make(map[string]bool)
|
||||
m.largeMultiSelected = make(map[string]bool)
|
||||
|
||||
// Reset scan counters for new scan
|
||||
atomic.StoreInt64(m.filesScanned, 0)
|
||||
atomic.StoreInt64(m.dirsScanned, 0)
|
||||
atomic.StoreInt64(m.bytesScanned, 0)
|
||||
|
||||
@@ -31,16 +31,14 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
|
||||
|
||||
var total int64
|
||||
|
||||
// Use heaps to track Top N items, drastically reducing memory usage
|
||||
// for directories with millions of files
|
||||
// Keep Top N heaps.
|
||||
entriesHeap := &entryHeap{}
|
||||
heap.Init(entriesHeap)
|
||||
|
||||
largeFilesHeap := &largeFileHeap{}
|
||||
heap.Init(largeFilesHeap)
|
||||
|
||||
// Use worker pool for concurrent directory scanning
|
||||
// For I/O-bound operations, use more workers than CPU count
|
||||
// Worker pool sized for I/O-bound scanning.
|
||||
numWorkers := runtime.NumCPU() * cpuMultiplier
|
||||
if numWorkers < minWorkers {
|
||||
numWorkers = minWorkers
|
||||
@@ -57,17 +55,15 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
|
||||
sem := make(chan struct{}, numWorkers)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Use channels to collect results without lock contention
|
||||
// Collect results via channels.
|
||||
entryChan := make(chan dirEntry, len(children))
|
||||
largeFileChan := make(chan fileEntry, maxLargeFiles*2)
|
||||
|
||||
// Start goroutines to collect from channels into heaps
|
||||
var collectorWg sync.WaitGroup
|
||||
collectorWg.Add(2)
|
||||
go func() {
|
||||
defer collectorWg.Done()
|
||||
for entry := range entryChan {
|
||||
// Maintain Top N Heap for entries
|
||||
if entriesHeap.Len() < maxEntries {
|
||||
heap.Push(entriesHeap, entry)
|
||||
} else if entry.Size > (*entriesHeap)[0].Size {
|
||||
@@ -79,7 +75,6 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
|
||||
go func() {
|
||||
defer collectorWg.Done()
|
||||
for file := range largeFileChan {
|
||||
// Maintain Top N Heap for large files
|
||||
if largeFilesHeap.Len() < maxLargeFiles {
|
||||
heap.Push(largeFilesHeap, file)
|
||||
} else if file.Size > (*largeFilesHeap)[0].Size {
|
||||
@@ -96,20 +91,15 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
|
||||
for _, child := range children {
|
||||
fullPath := filepath.Join(root, child.Name())
|
||||
|
||||
// Skip symlinks to avoid following them into unexpected locations
|
||||
// Use Type() instead of IsDir() to check without following symlinks
|
||||
// Skip symlinks to avoid following unexpected targets.
|
||||
if child.Type()&fs.ModeSymlink != 0 {
|
||||
// For symlinks, check if they point to a directory
|
||||
targetInfo, err := os.Stat(fullPath)
|
||||
isDir := false
|
||||
if err == nil && targetInfo.IsDir() {
|
||||
isDir = true
|
||||
}
|
||||
|
||||
// Get symlink size (we don't effectively count the target size towards parent to avoid double counting,
|
||||
// or we just count the link size itself. Existing logic counts 'size' via getActualFileSize on the link info).
|
||||
// Ideally we just want navigation.
|
||||
// Re-fetching info for link itself if needed, but child.Info() does that.
|
||||
// Count link size only to avoid double-counting targets.
|
||||
info, err := child.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
@@ -118,28 +108,26 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
|
||||
atomic.AddInt64(&total, size)
|
||||
|
||||
entryChan <- dirEntry{
|
||||
Name: child.Name() + " →", // Add arrow to indicate symlink
|
||||
Name: child.Name() + " →",
|
||||
Path: fullPath,
|
||||
Size: size,
|
||||
IsDir: isDir, // Allow navigation if target is directory
|
||||
IsDir: isDir,
|
||||
LastAccess: getLastAccessTimeFromInfo(info),
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if child.IsDir() {
|
||||
// Check if directory should be skipped based on user configuration
|
||||
if defaultSkipDirs[child.Name()] {
|
||||
continue
|
||||
}
|
||||
|
||||
// In root directory, skip system directories completely
|
||||
// Skip system dirs at root.
|
||||
if isRootDir && skipSystemDirs[child.Name()] {
|
||||
continue
|
||||
}
|
||||
|
||||
// Special handling for ~/Library - reuse cache to avoid duplicate scanning
|
||||
// This is scanned separately in overview mode
|
||||
// ~/Library is scanned separately; reuse cache when possible.
|
||||
if isHomeDir && child.Name() == "Library" {
|
||||
wg.Add(1)
|
||||
go func(name, path string) {
|
||||
@@ -148,14 +136,11 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
|
||||
defer func() { <-sem }()
|
||||
|
||||
var size int64
|
||||
// Try overview cache first (from overview scan)
|
||||
if cached, err := loadStoredOverviewSize(path); err == nil && cached > 0 {
|
||||
size = cached
|
||||
} else if cached, err := loadCacheFromDisk(path); err == nil {
|
||||
// Try disk cache
|
||||
size = cached.TotalSize
|
||||
} else {
|
||||
// No cache available, scan normally
|
||||
size = calculateDirSizeConcurrent(path, largeFileChan, filesScanned, dirsScanned, bytesScanned, currentPath)
|
||||
}
|
||||
atomic.AddInt64(&total, size)
|
||||
@@ -172,7 +157,7 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
|
||||
continue
|
||||
}
|
||||
|
||||
// For folded directories, calculate size quickly without expanding
|
||||
// Folded dirs: fast size without expanding.
|
||||
if shouldFoldDirWithPath(child.Name(), fullPath) {
|
||||
wg.Add(1)
|
||||
go func(name, path string) {
|
||||
@@ -180,10 +165,8 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
|
||||
// Try du command first for folded dirs (much faster)
|
||||
size, err := getDirectorySizeFromDu(path)
|
||||
if err != nil || size <= 0 {
|
||||
// Fallback to concurrent walk if du fails
|
||||
size = calculateDirSizeFast(path, filesScanned, dirsScanned, bytesScanned, currentPath)
|
||||
}
|
||||
atomic.AddInt64(&total, size)
|
||||
@@ -194,13 +177,12 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
|
||||
Path: path,
|
||||
Size: size,
|
||||
IsDir: true,
|
||||
LastAccess: time.Time{}, // Lazy load when displayed
|
||||
LastAccess: time.Time{},
|
||||
}
|
||||
}(child.Name(), fullPath)
|
||||
continue
|
||||
}
|
||||
|
||||
// Normal directory: full scan with detail
|
||||
wg.Add(1)
|
||||
go func(name, path string) {
|
||||
defer wg.Done()
|
||||
@@ -216,7 +198,7 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
|
||||
Path: path,
|
||||
Size: size,
|
||||
IsDir: true,
|
||||
LastAccess: time.Time{}, // Lazy load when displayed
|
||||
LastAccess: time.Time{},
|
||||
}
|
||||
}(child.Name(), fullPath)
|
||||
continue
|
||||
@@ -226,7 +208,7 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// Get actual disk usage for sparse files and cloud files
|
||||
// Actual disk usage for sparse/cloud files.
|
||||
size := getActualFileSize(fullPath, info)
|
||||
atomic.AddInt64(&total, size)
|
||||
atomic.AddInt64(filesScanned, 1)
|
||||
@@ -239,7 +221,7 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
|
||||
IsDir: false,
|
||||
LastAccess: getLastAccessTimeFromInfo(info),
|
||||
}
|
||||
// Only track large files that are not code/text files
|
||||
// Track large files only.
|
||||
if !shouldSkipFileForLargeTracking(fullPath) && size >= minLargeFileSize {
|
||||
largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size}
|
||||
}
|
||||
@@ -247,12 +229,12 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Close channels and wait for collectors to finish
|
||||
// Close channels and wait for collectors.
|
||||
close(entryChan)
|
||||
close(largeFileChan)
|
||||
collectorWg.Wait()
|
||||
|
||||
// Convert Heaps to sorted slices (Descending order)
|
||||
// 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)
|
||||
@@ -263,20 +245,11 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
|
||||
largeFiles[i] = heap.Pop(largeFilesHeap).(fileEntry)
|
||||
}
|
||||
|
||||
// Try to use Spotlight (mdfind) for faster large file discovery
|
||||
// This is a performance optimization that gracefully falls back to scan results
|
||||
// if Spotlight is unavailable or fails. The fallback is intentionally silent
|
||||
// because users only care about correct results, not the method used.
|
||||
// Use Spotlight for large files when available.
|
||||
if spotlightFiles := findLargeFilesWithSpotlight(root, minLargeFileSize); len(spotlightFiles) > 0 {
|
||||
// Spotlight results are already sorted top N
|
||||
// Use them in place of scanned large files
|
||||
largeFiles = spotlightFiles
|
||||
}
|
||||
|
||||
// Double check sorting consistency (Spotlight returns sorted, but heap pop handles scan results)
|
||||
// If needed, we could re-sort largeFiles, but heap pop ensures ascending, and we filled reverse, so it's Descending.
|
||||
// Spotlight returns Descending. So no extra sort needed for either.
|
||||
|
||||
return scanResult{
|
||||
Entries: entries,
|
||||
LargeFiles: largeFiles,
|
||||
@@ -285,21 +258,16 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
|
||||
}
|
||||
|
||||
func shouldFoldDirWithPath(name, path string) bool {
|
||||
// Check basic fold list first
|
||||
if foldDirs[name] {
|
||||
return true
|
||||
}
|
||||
|
||||
// Special case: npm cache directories - fold all subdirectories
|
||||
// This includes: .npm/_quick/*, .npm/_cacache/*, .npm/a-z/*, .tnpm/*
|
||||
// Handle npm cache structure.
|
||||
if strings.Contains(path, "/.npm/") || strings.Contains(path, "/.tnpm/") {
|
||||
// Get the parent directory name
|
||||
parent := filepath.Base(filepath.Dir(path))
|
||||
// If parent is a cache folder (_quick, _cacache, etc) or npm dir itself, fold it
|
||||
if parent == ".npm" || parent == ".tnpm" || strings.HasPrefix(parent, "_") {
|
||||
return true
|
||||
}
|
||||
// Also fold single-letter subdirectories (npm cache structure like .npm/a/, .npm/b/)
|
||||
if len(name) == 1 {
|
||||
return true
|
||||
}
|
||||
@@ -313,17 +281,14 @@ func shouldSkipFileForLargeTracking(path string) bool {
|
||||
return skipExtensions[ext]
|
||||
}
|
||||
|
||||
// calculateDirSizeFast performs concurrent directory size calculation using os.ReadDir
|
||||
// This is a faster fallback than filepath.WalkDir when du fails
|
||||
// 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
|
||||
|
||||
// Create context with timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
// Limit total concurrency for this walk
|
||||
concurrency := runtime.NumCPU() * 4
|
||||
if concurrency > 64 {
|
||||
concurrency = 64
|
||||
@@ -351,19 +316,16 @@ func calculateDirSizeFast(root string, filesScanned, dirsScanned, bytesScanned *
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
// Directories: recurse concurrently
|
||||
wg.Add(1)
|
||||
// Capture loop variable
|
||||
subDir := filepath.Join(dirPath, entry.Name())
|
||||
go func(p string) {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{} // Acquire token
|
||||
defer func() { <-sem }() // Release token
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
walk(p)
|
||||
}(subDir)
|
||||
atomic.AddInt64(dirsScanned, 1)
|
||||
} else {
|
||||
// Files: process immediately
|
||||
info, err := entry.Info()
|
||||
if err == nil {
|
||||
size := getActualFileSize(filepath.Join(dirPath, entry.Name()), info)
|
||||
@@ -388,9 +350,8 @@ func calculateDirSizeFast(root string, filesScanned, dirsScanned, bytesScanned *
|
||||
return total
|
||||
}
|
||||
|
||||
// Use Spotlight (mdfind) to quickly find large files in a directory
|
||||
// Use Spotlight (mdfind) to quickly find large files.
|
||||
func findLargeFilesWithSpotlight(root string, minSize int64) []fileEntry {
|
||||
// mdfind query: files >= minSize in the specified directory
|
||||
query := fmt.Sprintf("kMDItemFSSize >= %d", minSize)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), mdlsTimeout)
|
||||
@@ -399,7 +360,6 @@ func findLargeFilesWithSpotlight(root string, minSize int64) []fileEntry {
|
||||
cmd := exec.CommandContext(ctx, "mdfind", "-onlyin", root, query)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
// Fallback: mdfind not available or failed
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -411,28 +371,26 @@ func findLargeFilesWithSpotlight(root string, minSize int64) []fileEntry {
|
||||
continue
|
||||
}
|
||||
|
||||
// Filter out code files first (cheapest check, no I/O)
|
||||
// Filter code files first (cheap).
|
||||
if shouldSkipFileForLargeTracking(line) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Filter out files in folded directories (cheap string check)
|
||||
// Filter folded directories (cheap string check).
|
||||
if isInFoldedDir(line) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Use Lstat instead of Stat (faster, doesn't follow symlinks)
|
||||
info, err := os.Lstat(line)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip if it's a directory or symlink
|
||||
if info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get actual disk usage for sparse files and cloud files
|
||||
// Actual disk usage for sparse/cloud files.
|
||||
actualSize := getActualFileSize(line, info)
|
||||
files = append(files, fileEntry{
|
||||
Name: filepath.Base(line),
|
||||
@@ -441,12 +399,11 @@ func findLargeFilesWithSpotlight(root string, minSize int64) []fileEntry {
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by size (descending)
|
||||
// Sort by size (descending).
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
return files[i].Size > files[j].Size
|
||||
})
|
||||
|
||||
// Return top N
|
||||
if len(files) > maxLargeFiles {
|
||||
files = files[:maxLargeFiles]
|
||||
}
|
||||
@@ -454,9 +411,8 @@ func findLargeFilesWithSpotlight(root string, minSize int64) []fileEntry {
|
||||
return files
|
||||
}
|
||||
|
||||
// isInFoldedDir checks if a path is inside a folded directory (optimized)
|
||||
// isInFoldedDir checks if a path is inside a folded directory.
|
||||
func isInFoldedDir(path string) bool {
|
||||
// Split path into components for faster checking
|
||||
parts := strings.Split(path, string(os.PathSeparator))
|
||||
for _, part := range parts {
|
||||
if foldDirs[part] {
|
||||
@@ -467,7 +423,6 @@ func isInFoldedDir(path string) bool {
|
||||
}
|
||||
|
||||
func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, filesScanned, dirsScanned, bytesScanned *int64, currentPath *string) int64 {
|
||||
// Read immediate children
|
||||
children, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
return 0
|
||||
@@ -476,7 +431,7 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, fil
|
||||
var total int64
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Limit concurrent subdirectory scans to avoid too many goroutines
|
||||
// Limit concurrent subdirectory scans.
|
||||
maxConcurrent := runtime.NumCPU() * 2
|
||||
if maxConcurrent > maxDirWorkers {
|
||||
maxConcurrent = maxDirWorkers
|
||||
@@ -486,9 +441,7 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, fil
|
||||
for _, child := range children {
|
||||
fullPath := filepath.Join(root, child.Name())
|
||||
|
||||
// Skip symlinks to avoid following them into unexpected locations
|
||||
if child.Type()&fs.ModeSymlink != 0 {
|
||||
// For symlinks, just count their size without following
|
||||
info, err := child.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
@@ -501,9 +454,7 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, fil
|
||||
}
|
||||
|
||||
if child.IsDir() {
|
||||
// Check if this is a folded directory
|
||||
if shouldFoldDirWithPath(child.Name(), fullPath) {
|
||||
// Use du for folded directories (much faster)
|
||||
wg.Add(1)
|
||||
go func(path string) {
|
||||
defer wg.Done()
|
||||
@@ -517,7 +468,6 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, fil
|
||||
continue
|
||||
}
|
||||
|
||||
// Recursively scan subdirectory in parallel
|
||||
wg.Add(1)
|
||||
go func(path string) {
|
||||
defer wg.Done()
|
||||
@@ -531,7 +481,6 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, fil
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle files
|
||||
info, err := child.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
@@ -542,12 +491,11 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, fil
|
||||
atomic.AddInt64(filesScanned, 1)
|
||||
atomic.AddInt64(bytesScanned, size)
|
||||
|
||||
// Track large files
|
||||
if !shouldSkipFileForLargeTracking(fullPath) && size >= minLargeFileSize {
|
||||
largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size}
|
||||
}
|
||||
|
||||
// Update current path occasionally to prevent UI jitter
|
||||
// Update current path occasionally to prevent UI jitter.
|
||||
if currentPath != nil && atomic.LoadInt64(filesScanned)%int64(batchUpdateSize) == 0 {
|
||||
*currentPath = fullPath
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// View renders the TUI display.
|
||||
// View renders the TUI.
|
||||
func (m model) View() string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintln(&b)
|
||||
@@ -16,7 +16,6 @@ func (m model) View() string {
|
||||
if m.inOverviewMode() {
|
||||
fmt.Fprintf(&b, "%sAnalyze Disk%s\n", colorPurpleBold, colorReset)
|
||||
if m.overviewScanning {
|
||||
// Check if we're in initial scan (all entries are pending)
|
||||
allPending := true
|
||||
for _, entry := range m.entries {
|
||||
if entry.Size >= 0 {
|
||||
@@ -26,19 +25,16 @@ func (m model) View() string {
|
||||
}
|
||||
|
||||
if allPending {
|
||||
// Show prominent loading screen for initial scan
|
||||
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 {
|
||||
// Progressive scanning - show subtle indicator
|
||||
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 {
|
||||
// Check if there are still pending items
|
||||
hasPending := false
|
||||
for _, entry := range m.entries {
|
||||
if entry.Size < 0 {
|
||||
@@ -62,7 +58,6 @@ func (m model) View() string {
|
||||
}
|
||||
|
||||
if m.deleting {
|
||||
// Show delete progress
|
||||
count := int64(0)
|
||||
if m.deleteCount != nil {
|
||||
count = atomic.LoadInt64(m.deleteCount)
|
||||
@@ -130,7 +125,6 @@ func (m model) View() string {
|
||||
sizeColor := colorGray
|
||||
numColor := ""
|
||||
|
||||
// Check if this item is multi-selected (by path, not index)
|
||||
isMultiSelected := m.largeMultiSelected != nil && m.largeMultiSelected[file.Path]
|
||||
selectIcon := "○"
|
||||
if isMultiSelected {
|
||||
@@ -164,8 +158,7 @@ func (m model) View() string {
|
||||
}
|
||||
}
|
||||
totalSize := m.totalSize
|
||||
// For overview mode, use a fixed small width since path names are short
|
||||
// (~/Downloads, ~/Library, etc. - max ~15 chars)
|
||||
// Overview paths are short; fixed width keeps layout stable.
|
||||
nameWidth := 20
|
||||
for idx, entry := range m.entries {
|
||||
icon := "📁"
|
||||
@@ -217,12 +210,10 @@ func (m model) View() string {
|
||||
}
|
||||
displayIndex := idx + 1
|
||||
|
||||
// Priority: cleanable > unused time
|
||||
var hintLabel string
|
||||
if entry.IsDir && isCleanableDir(entry.Path) {
|
||||
hintLabel = fmt.Sprintf("%s🧹%s", colorYellow, colorReset)
|
||||
} else {
|
||||
// For overview mode, get access time on-demand if not set
|
||||
lastAccess := entry.LastAccess
|
||||
if lastAccess.IsZero() && entry.Path != "" {
|
||||
lastAccess = getLastAccessTime(entry.Path)
|
||||
@@ -243,7 +234,6 @@ func (m model) View() string {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Normal mode with sizes and progress bars
|
||||
maxSize := int64(1)
|
||||
for _, entry := range m.entries {
|
||||
if entry.Size > maxSize {
|
||||
@@ -272,14 +262,11 @@ func (m model) View() string {
|
||||
name := trimNameWithWidth(entry.Name, nameWidth)
|
||||
paddedName := padName(name, nameWidth)
|
||||
|
||||
// Calculate percentage
|
||||
percent := float64(entry.Size) / float64(m.totalSize) * 100
|
||||
percentStr := fmt.Sprintf("%5.1f%%", percent)
|
||||
|
||||
// Get colored progress bar
|
||||
bar := coloredProgressBar(entry.Size, maxSize, percent)
|
||||
|
||||
// Color the size based on magnitude
|
||||
var sizeColor string
|
||||
if percent >= 50 {
|
||||
sizeColor = colorRed
|
||||
@@ -291,7 +278,6 @@ func (m model) View() string {
|
||||
sizeColor = colorGray
|
||||
}
|
||||
|
||||
// Check if this item is multi-selected (by path, not index)
|
||||
isMultiSelected := m.multiSelected != nil && m.multiSelected[entry.Path]
|
||||
selectIcon := "○"
|
||||
nameColor := ""
|
||||
@@ -300,7 +286,6 @@ func (m model) View() string {
|
||||
nameColor = colorGreen
|
||||
}
|
||||
|
||||
// Keep chart columns aligned even when arrow is shown
|
||||
entryPrefix := " "
|
||||
nameSegment := fmt.Sprintf("%s %s", icon, paddedName)
|
||||
if nameColor != "" {
|
||||
@@ -320,12 +305,10 @@ func (m model) View() string {
|
||||
|
||||
displayIndex := idx + 1
|
||||
|
||||
// Priority: cleanable > unused time
|
||||
var hintLabel string
|
||||
if entry.IsDir && isCleanableDir(entry.Path) {
|
||||
hintLabel = fmt.Sprintf("%s🧹%s", colorYellow, colorReset)
|
||||
} else {
|
||||
// Get access time on-demand if not set
|
||||
lastAccess := entry.LastAccess
|
||||
if lastAccess.IsZero() && entry.Path != "" {
|
||||
lastAccess = getLastAccessTime(entry.Path)
|
||||
@@ -351,7 +334,6 @@ func (m model) View() string {
|
||||
|
||||
fmt.Fprintln(&b)
|
||||
if m.inOverviewMode() {
|
||||
// Show ← Back if there's history (entered from a parent directory)
|
||||
if len(m.history) > 0 {
|
||||
fmt.Fprintf(&b, "%s↑↓←→ | Enter | R Refresh | O Open | F File | ← Back | Q Quit%s\n", colorGray, colorReset)
|
||||
} else {
|
||||
@@ -383,12 +365,10 @@ func (m model) View() string {
|
||||
}
|
||||
if m.deleteConfirm && m.deleteTarget != nil {
|
||||
fmt.Fprintln(&b)
|
||||
// Show multi-selection delete info if applicable
|
||||
var deleteCount int
|
||||
var totalDeleteSize int64
|
||||
if m.showLargeFiles && len(m.largeMultiSelected) > 0 {
|
||||
deleteCount = len(m.largeMultiSelected)
|
||||
// Calculate total size by looking up each selected path
|
||||
for path := range m.largeMultiSelected {
|
||||
for _, file := range m.largeFiles {
|
||||
if file.Path == path {
|
||||
@@ -399,7 +379,6 @@ func (m model) View() string {
|
||||
}
|
||||
} else if !m.showLargeFiles && len(m.multiSelected) > 0 {
|
||||
deleteCount = len(m.multiSelected)
|
||||
// Calculate total size by looking up each selected path
|
||||
for path := range m.multiSelected {
|
||||
for _, entry := range m.entries {
|
||||
if entry.Path == path {
|
||||
@@ -425,27 +404,24 @@ func (m model) View() string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// calculateViewport computes the number of visible items based on terminal height.
|
||||
// calculateViewport returns visible rows for the current terminal height.
|
||||
func calculateViewport(termHeight int, isLargeFiles bool) int {
|
||||
if termHeight <= 0 {
|
||||
// Terminal height unknown, use default
|
||||
return defaultViewport
|
||||
}
|
||||
|
||||
// Calculate reserved space for UI elements
|
||||
reserved := 6 // header (3-4 lines) + footer (2 lines)
|
||||
reserved := 6 // Header + footer
|
||||
if isLargeFiles {
|
||||
reserved = 5 // Large files view has less overhead
|
||||
reserved = 5
|
||||
}
|
||||
|
||||
available := termHeight - reserved
|
||||
|
||||
// Ensure minimum and maximum bounds
|
||||
if available < 1 {
|
||||
return 1 // Minimum 1 line for very short terminals
|
||||
return 1
|
||||
}
|
||||
if available > 30 {
|
||||
return 30 // Maximum 30 lines to avoid information overload
|
||||
return 30
|
||||
}
|
||||
|
||||
return available
|
||||
|
||||
Reference in New Issue
Block a user