1
0
mirror of https://github.com/tw93/Mole.git synced 2026-02-12 21:00:13 +00:00

Disk Analyzer performance optimization and UI improvement

This commit is contained in:
Tw93
2025-11-16 09:01:04 +08:00
parent 677caaa947
commit bebcf4d166
2 changed files with 228 additions and 204 deletions

Binary file not shown.

View File

@@ -27,8 +27,8 @@ import (
) )
const ( const (
maxEntries = 20 maxEntries = 30
maxLargeFiles = 20 maxLargeFiles = 30
barWidth = 24 barWidth = 24
minLargeFileSize = 100 << 20 // 100 MB minLargeFileSize = 100 << 20 // 100 MB
entryViewport = 10 entryViewport = 10
@@ -141,6 +141,13 @@ var foldDirs = map[string]bool{
".sdkman": true, // SDK manager ".sdkman": true, // SDK manager
".nvm": true, // Node version manager ".nvm": true, // Node version manager
// macOS specific
"Application Scripts": true, // macOS sandboxed app scripts (can have many subdirs)
"Saved Application State": true, // App state snapshots
// iCloud
"Mobile Documents": true, // iCloud Drive - avoid triggering downloads
// Docker & Containers // Docker & Containers
".docker": true, ".docker": true,
".containerd": true, ".containerd": true,
@@ -222,7 +229,8 @@ var skipExtensions = map[string]bool{
".svg": true, ".svg": true,
} }
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧"} // Classic visible spinner
var spinnerFrames = []string{"|", "/", "-", "\\", "|", "/", "-", "\\"}
// Global singleflight group to avoid duplicate scans of the same path // Global singleflight group to avoid duplicate scans of the same path
var scanGroup singleflight.Group var scanGroup singleflight.Group
@@ -1065,18 +1073,21 @@ func (m model) View() string {
for idx := start; idx < end; idx++ { for idx := start; idx < end; idx++ {
file := m.largeFiles[idx] file := m.largeFiles[idx]
shortPath := displayPath(file.path) shortPath := displayPath(file.path)
shortPath = truncateMiddle(shortPath, 56) shortPath = truncateMiddle(shortPath, 35)
entryPrefix := " " entryPrefix := " "
nameColor := "" nameColor := ""
sizeColor := colorGray
numColor := ""
if idx == m.largeSelected { if idx == m.largeSelected {
entryPrefix = fmt.Sprintf(" %s%s▶%s ", colorCyan, colorBold, colorReset) entryPrefix = fmt.Sprintf(" %s%s▶%s ", colorCyan, colorBold, colorReset)
nameColor = colorCyan // Highlight filename with cyan nameColor = colorCyan
sizeColor = colorCyan
numColor = colorCyan
} }
nameColumn := padName(shortPath, 56)
size := humanizeBytes(file.size) size := humanizeBytes(file.size)
bar := coloredProgressBar(file.size, maxLargeSize, 0) bar := coloredProgressBar(file.size, maxLargeSize, 0)
fmt.Fprintf(&b, "%s%2d. %s | 📄 %s%s%s %s%10s%s\n", fmt.Fprintf(&b, "%s%s%2d.%s %s | 📄 %s%s%s %s%10s%s\n",
entryPrefix, idx+1, bar, nameColor, nameColumn, colorReset, colorGray, size, colorReset) entryPrefix, numColor, idx+1, colorReset, bar, nameColor, shortPath, colorReset, sizeColor, size, colorReset)
} }
} }
} else { } else {
@@ -1130,9 +1141,14 @@ func (m model) View() string {
name := trimName(entry.name) name := trimName(entry.name)
paddedName := padName(name, 28) paddedName := padName(name, 28)
nameSegment := fmt.Sprintf("%s %s", icon, paddedName) nameSegment := fmt.Sprintf("%s %s", icon, paddedName)
numColor := ""
percentColor := ""
if idx == m.selected { if idx == m.selected {
entryPrefix = fmt.Sprintf(" %s%s▶%s ", colorCyan, colorBold, colorReset) entryPrefix = fmt.Sprintf(" %s%s▶%s ", colorCyan, colorBold, colorReset)
nameSegment = fmt.Sprintf("%s%s %s%s", colorCyan, icon, paddedName, colorReset) nameSegment = fmt.Sprintf("%s%s %s%s", colorCyan, icon, paddedName, colorReset)
numColor = colorCyan
percentColor = colorCyan
sizeColor = colorCyan
} }
displayIndex := idx + 1 displayIndex := idx + 1
@@ -1146,12 +1162,12 @@ func (m model) View() string {
} }
unusedLabel := formatUnusedTime(lastAccess) unusedLabel := formatUnusedTime(lastAccess)
if unusedLabel == "" { if unusedLabel == "" {
fmt.Fprintf(&b, "%s%2d. %s %s | %s %s%10s%s\n", fmt.Fprintf(&b, "%s%s%2d.%s %s %s%s%s | %s %s%10s%s\n",
entryPrefix, displayIndex, bar, percentStr, entryPrefix, numColor, displayIndex, colorReset, bar, percentColor, percentStr, colorReset,
nameSegment, sizeColor, sizeText, colorReset) nameSegment, sizeColor, sizeText, colorReset)
} else { } else {
fmt.Fprintf(&b, "%s%2d. %s %s | %s %s%10s%s %s%s%s\n", fmt.Fprintf(&b, "%s%s%2d.%s %s %s%s%s | %s %s%10s%s %s%s%s\n",
entryPrefix, displayIndex, bar, percentStr, entryPrefix, numColor, displayIndex, colorReset, bar, percentColor, percentStr, colorReset,
nameSegment, sizeColor, sizeText, colorReset, nameSegment, sizeColor, sizeText, colorReset,
colorGray, unusedLabel, colorReset) colorGray, unusedLabel, colorReset)
} }
@@ -1206,9 +1222,14 @@ func (m model) View() string {
// Keep chart columns aligned even when arrow is shown // Keep chart columns aligned even when arrow is shown
entryPrefix := " " entryPrefix := " "
nameSegment := fmt.Sprintf("%s %s", icon, paddedName) nameSegment := fmt.Sprintf("%s %s", icon, paddedName)
numColor := ""
percentColor := ""
if idx == m.selected { if idx == m.selected {
entryPrefix = fmt.Sprintf(" %s%s▶%s ", colorCyan, colorBold, colorReset) entryPrefix = fmt.Sprintf(" %s%s▶%s ", colorCyan, colorBold, colorReset)
nameSegment = fmt.Sprintf("%s%s %s%s", colorCyan, icon, paddedName, colorReset) nameSegment = fmt.Sprintf("%s%s %s%s", colorCyan, icon, paddedName, colorReset)
numColor = colorCyan
percentColor = colorCyan
sizeColor = colorCyan
} }
displayIndex := idx + 1 displayIndex := idx + 1
@@ -1216,12 +1237,12 @@ func (m model) View() string {
// Add unused time label if applicable // Add unused time label if applicable
unusedLabel := formatUnusedTime(entry.lastAccess) unusedLabel := formatUnusedTime(entry.lastAccess)
if unusedLabel == "" { if unusedLabel == "" {
fmt.Fprintf(&b, "%s%2d. %s %s | %s %s%10s%s\n", fmt.Fprintf(&b, "%s%s%2d.%s %s %s%s%s | %s %s%10s%s\n",
entryPrefix, displayIndex, bar, percentStr, entryPrefix, numColor, displayIndex, colorReset, bar, percentColor, percentStr, colorReset,
nameSegment, sizeColor, size, colorReset) nameSegment, sizeColor, size, colorReset)
} else { } else {
fmt.Fprintf(&b, "%s%2d. %s %s | %s %s%10s%s %s%s%s\n", fmt.Fprintf(&b, "%s%s%2d.%s %s %s%s%s | %s %s%10s%s %s%s%s\n",
entryPrefix, displayIndex, bar, percentStr, entryPrefix, numColor, displayIndex, colorReset, bar, percentColor, percentStr, colorReset,
nameSegment, sizeColor, size, colorReset, nameSegment, sizeColor, size, colorReset,
colorGray, unusedLabel, colorReset) colorGray, unusedLabel, colorReset)
} }
@@ -1232,15 +1253,15 @@ func (m model) View() string {
fmt.Fprintln(&b) fmt.Fprintln(&b)
if m.isOverview { if m.isOverview {
fmt.Fprintf(&b, "%s ↑↓←→ Navigate | Enter Explore | O Open | F Reveal | Q Quit%s\n", colorGray, colorReset) fmt.Fprintf(&b, "%s↑/↓ Nav | Enter | O Open | F Reveal | Q Quit%s\n", colorGray, colorReset)
} else if m.showLargeFiles { } else if m.showLargeFiles {
fmt.Fprintf(&b, "%s ↑↓ Navigate | O Open | F Reveal | ⌫ Delete | Q Quit%s\n", colorGray, colorReset) fmt.Fprintf(&b, "%s↑/↓ Nav | O Open | F Reveal | ⌫ Delete | L Back | Q Quit%s\n", colorGray, colorReset)
} else { } else {
largeFileCount := len(m.largeFiles) largeFileCount := len(m.largeFiles)
if largeFileCount > 0 { if largeFileCount > 0 {
fmt.Fprintf(&b, "%s ↑↓←→ Navigate | O Open | F Reveal | ⌫ Delete | L Large(%d) | Q Quit%s\n", colorGray, largeFileCount, colorReset) fmt.Fprintf(&b, "%s↑/↓/←/→ Nav | Enter | O Open | F Reveal | ⌫ Delete | L Large(%d) | Q Quit%s\n", colorGray, largeFileCount, colorReset)
} else { } else {
fmt.Fprintf(&b, "%s ↑↓←→ Navigate | O Open | F Reveal | ⌫ Delete | Q Quit%s\n", colorGray, colorReset) fmt.Fprintf(&b, "%s↑/↓/←/→ Nav | Enter | O Open | F Reveal | ⌫ Delete | Q Quit%s\n", colorGray, colorReset)
} }
} }
return b.String() return b.String()
@@ -1252,10 +1273,9 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
return scanResult{}, err return scanResult{}, err
} }
tracker := newLargeFileTracker()
var total int64 var total int64
entries := make([]dirEntry, 0, len(children)) entries := make([]dirEntry, 0, len(children))
var entriesMu sync.Mutex largeFiles := make([]fileEntry, 0, maxLargeFiles*2)
// Use worker pool for concurrent directory scanning // Use worker pool for concurrent directory scanning
// For I/O-bound operations, use more workers than CPU count // For I/O-bound operations, use more workers than CPU count
@@ -1276,6 +1296,26 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
sem := make(chan struct{}, maxWorkers) sem := make(chan struct{}, maxWorkers)
var wg sync.WaitGroup var wg sync.WaitGroup
// Use channels to collect results without lock contention
entryChan := make(chan dirEntry, len(children))
largeFileChan := make(chan fileEntry, maxLargeFiles*2)
// Start goroutines to collect from channels
var collectorWg sync.WaitGroup
collectorWg.Add(2)
go func() {
defer collectorWg.Done()
for entry := range entryChan {
entries = append(entries, entry)
}
}()
go func() {
defer collectorWg.Done()
for file := range largeFileChan {
largeFiles = append(largeFiles, file)
}
}()
isRootDir := root == "/" isRootDir := root == "/"
for _, child := range children { for _, child := range children {
@@ -1304,16 +1344,13 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
atomic.AddInt64(&total, size) atomic.AddInt64(&total, size)
atomic.AddInt64(dirsScanned, 1) atomic.AddInt64(dirsScanned, 1)
entry := dirEntry{ entryChan <- dirEntry{
name: name, name: name,
path: path, path: path,
size: size, size: size,
isDir: true, isDir: true,
lastAccess: getLastAccessTime(path), lastAccess: time.Time{}, // Lazy load when displayed
} }
entriesMu.Lock()
entries = append(entries, entry)
entriesMu.Unlock()
}(child.Name(), fullPath) }(child.Name(), fullPath)
continue continue
} }
@@ -1325,20 +1362,17 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
sem <- struct{}{} sem <- struct{}{}
defer func() { <-sem }() defer func() { <-sem }()
size := calculateDirSizeConcurrent(path, tracker, filesScanned, dirsScanned, bytesScanned, currentPath) size := calculateDirSizeConcurrent(path, largeFileChan, filesScanned, dirsScanned, bytesScanned, currentPath)
atomic.AddInt64(&total, size) atomic.AddInt64(&total, size)
atomic.AddInt64(dirsScanned, 1) atomic.AddInt64(dirsScanned, 1)
entry := dirEntry{ entryChan <- dirEntry{
name: name, name: name,
path: path, path: path,
size: size, size: size,
isDir: true, isDir: true,
lastAccess: getLastAccessTime(path), lastAccess: time.Time{}, // Lazy load when displayed
} }
entriesMu.Lock()
entries = append(entries, entry)
entriesMu.Unlock()
}(child.Name(), fullPath) }(child.Name(), fullPath)
continue continue
} }
@@ -1353,21 +1387,26 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
atomic.AddInt64(filesScanned, 1) atomic.AddInt64(filesScanned, 1)
atomic.AddInt64(bytesScanned, size) atomic.AddInt64(bytesScanned, size)
entries = append(entries, dirEntry{ entryChan <- dirEntry{
name: child.Name(), name: child.Name(),
path: fullPath, path: fullPath,
size: size, size: size,
isDir: false, isDir: false,
lastAccess: getLastAccessTime(fullPath), lastAccess: getLastAccessTimeFromInfo(info),
}) }
// Only track large files that are not code/text files // Only track large files that are not code/text files
if !shouldSkipFileForLargeTracking(fullPath) { if !shouldSkipFileForLargeTracking(fullPath) && size >= minLargeFileSize {
tracker.add(fileEntry{name: child.Name(), path: fullPath, size: size}) largeFileChan <- fileEntry{name: child.Name(), path: fullPath, size: size}
} }
} }
wg.Wait() wg.Wait()
// Close channels and wait for collectors to finish
close(entryChan)
close(largeFileChan)
collectorWg.Wait()
sort.Slice(entries, func(i, j int) bool { sort.Slice(entries, func(i, j int) bool {
return entries[i].size > entries[j].size return entries[i].size > entries[j].size
}) })
@@ -1376,12 +1415,16 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
} }
// Try to use Spotlight for faster large file discovery // Try to use Spotlight for faster large file discovery
var largeFiles []fileEntry
if spotlightFiles := findLargeFilesWithSpotlight(root, minLargeFileSize); len(spotlightFiles) > 0 { if spotlightFiles := findLargeFilesWithSpotlight(root, minLargeFileSize); len(spotlightFiles) > 0 {
largeFiles = spotlightFiles largeFiles = spotlightFiles
} else { } else {
// Fallback to manual tracking // Sort and trim large files collected from scanning
largeFiles = tracker.list() sort.Slice(largeFiles, func(i, j int) bool {
return largeFiles[i].size > largeFiles[j].size
})
if len(largeFiles) > maxLargeFiles {
largeFiles = largeFiles[:maxLargeFiles]
}
} }
return scanResult{ return scanResult{
@@ -1402,16 +1445,16 @@ func shouldFoldDirWithPath(name, path string) bool {
return true return true
} }
// Special case: .npm directory - fold all subdirectories under cache folders // Special case: npm cache directories - fold all subdirectories
// This includes: .npm/_quick/*, .npm/_cacache/*, .npm/*/ // This includes: .npm/_quick/*, .npm/_cacache/*, .npm/a-z/*, .tnpm/*
if strings.Contains(path, "/.npm/") { if strings.Contains(path, "/.npm/") || strings.Contains(path, "/.tnpm/") {
// Get the parent directory name // Get the parent directory name
parent := filepath.Base(filepath.Dir(path)) parent := filepath.Base(filepath.Dir(path))
// If parent is a cache folder (_quick, _cacache, etc) or .npm itself, fold it // If parent is a cache folder (_quick, _cacache, etc) or npm dir itself, fold it
if parent == ".npm" || strings.HasPrefix(parent, "_") { if parent == ".npm" || parent == ".tnpm" || strings.HasPrefix(parent, "_") {
return true return true
} }
// Also fold single-letter subdirectories (npm cache structure) // Also fold single-letter subdirectories (npm cache structure like .npm/a/, .npm/b/)
if len(name) == 1 { if len(name) == 1 {
return true return true
} }
@@ -1423,7 +1466,7 @@ func shouldFoldDirWithPath(name, path string) bool {
// calculateDirSizeWithDu uses du command for fast directory size calculation // calculateDirSizeWithDu uses du command for fast directory size calculation
// Returns size in bytes, or 0 if command fails // Returns size in bytes, or 0 if command fails
func calculateDirSizeWithDu(path string) int64 { func calculateDirSizeWithDu(path string) int64 {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel() defer cancel()
// Use -sk for 1K-block output, then convert to bytes // Use -sk for 1K-block output, then convert to bytes
@@ -1540,30 +1583,27 @@ func findLargeFilesWithSpotlight(root string, minSize int64) []fileEntry {
continue continue
} }
// Check if it's a directory, skip it // Filter out code files first (cheapest check, no I/O)
info, err := os.Stat(line)
if err != nil || info.IsDir() {
continue
}
// Filter out files in folded directories
inFoldedDir := false
for foldDir := range foldDirs {
if strings.Contains(line, string(os.PathSeparator)+foldDir+string(os.PathSeparator)) ||
strings.HasSuffix(filepath.Dir(line), string(os.PathSeparator)+foldDir) {
inFoldedDir = true
break
}
}
if inFoldedDir {
continue
}
// Filter out code files
if shouldSkipFileForLargeTracking(line) { if shouldSkipFileForLargeTracking(line) {
continue continue
} }
// Filter out files in 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 // Get actual disk usage for sparse files and cloud files
actualSize := getActualFileSize(line, info) actualSize := getActualFileSize(line, info)
files = append(files, fileEntry{ files = append(files, fileEntry{
@@ -1586,151 +1626,95 @@ func findLargeFilesWithSpotlight(root string, minSize int64) []fileEntry {
return files return files
} }
func calculateDirSizeConcurrent(root string, tracker *largeFileTracker, filesScanned, dirsScanned, bytesScanned *int64, currentPath *string) int64 { // isInFoldedDir checks if a path is inside a folded directory (optimized)
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] {
return true
}
}
return false
}
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
}
var total int64 var total int64
var updateCounter int64 var wg sync.WaitGroup
var localFiles, localDirs int64
var batchBytes int64
// Create context with timeout for very large directories // Limit concurrent subdirectory scans to avoid too many goroutines
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) maxConcurrent := runtime.NumCPU() * 2
defer cancel() if maxConcurrent > 32 {
maxConcurrent = 32
}
sem := make(chan struct{}, maxConcurrent)
walkFunc := func(path string, d fs.DirEntry, err error) error { for _, child := range children {
// Check for context cancellation fullPath := filepath.Join(root, child.Name())
select {
case <-ctx.Done(): if child.IsDir() {
return ctx.Err() // Check if this is a folded directory
default: if shouldFoldDirWithPath(child.Name(), fullPath) {
} // Use du for folded directories (much faster)
if err != nil { wg.Add(1)
return nil go func(path string) {
} defer wg.Done()
if d.IsDir() { size := calculateDirSizeWithDu(path)
// Skip folded directories during recursive scanning, but calculate their size first if size > 0 {
if shouldFoldDirWithPath(d.Name(), path) { atomic.AddInt64(&total, size)
// Calculate folded directory size and add to parent total atomic.AddInt64(bytesScanned, size)
foldedSize := calculateDirSizeWithDu(path) atomic.AddInt64(dirsScanned, 1)
if foldedSize > 0 { }
total += foldedSize }(fullPath)
atomic.AddInt64(bytesScanned, foldedSize) continue
}
return filepath.SkipDir
} }
localDirs++
// Batch update every N dirs to reduce atomic operations // Recursively scan subdirectory in parallel
if localDirs%batchUpdateSize == 0 { wg.Add(1)
atomic.AddInt64(dirsScanned, batchUpdateSize) go func(path string) {
localDirs = 0 defer wg.Done()
} sem <- struct{}{}
return nil defer func() { <-sem }()
size := calculateDirSizeConcurrent(path, largeFileChan, filesScanned, dirsScanned, bytesScanned, currentPath)
atomic.AddInt64(&total, size)
atomic.AddInt64(dirsScanned, 1)
}(fullPath)
continue
} }
info, err := d.Info()
// Handle files
info, err := child.Info()
if err != nil { if err != nil {
return nil continue
} }
// Get actual disk usage for sparse files and cloud files
size := getActualFileSize(path, info) size := getActualFileSize(fullPath, info)
total += size total += size
batchBytes += size atomic.AddInt64(filesScanned, 1)
localFiles++ atomic.AddInt64(bytesScanned, size)
// Batch update every N files to reduce atomic operations // Track large files
if localFiles%batchUpdateSize == 0 { if !shouldSkipFileForLargeTracking(fullPath) && size >= minLargeFileSize {
atomic.AddInt64(filesScanned, batchUpdateSize) largeFileChan <- fileEntry{name: child.Name(), path: fullPath, size: size}
atomic.AddInt64(bytesScanned, batchBytes)
localFiles = 0
batchBytes = 0
} }
// Only track large files that are not code/text files // Update current path
if !shouldSkipFileForLargeTracking(path) { if currentPath != nil {
tracker.add(fileEntry{name: filepath.Base(path), path: path, size: size}) *currentPath = fullPath
} }
// Update current path periodically to reduce contention
updateCounter++
if updateCounter%pathUpdateInterval == 0 && currentPath != nil {
*currentPath = path
}
return nil
}
_ = filepath.WalkDir(root, walkFunc)
// Final update for remaining counts
if localFiles > 0 {
atomic.AddInt64(filesScanned, localFiles)
}
if localDirs > 0 {
atomic.AddInt64(dirsScanned, localDirs)
}
if batchBytes > 0 {
atomic.AddInt64(bytesScanned, batchBytes)
} }
wg.Wait()
return total return total
} }
type largeFileTracker struct {
mu sync.Mutex
entries []fileEntry
minSize int64
needsSort bool
}
func newLargeFileTracker() *largeFileTracker {
return &largeFileTracker{
entries: make([]fileEntry, 0, maxLargeFiles*2), // Pre-allocate more space
minSize: minLargeFileSize,
}
}
func (t *largeFileTracker) add(f fileEntry) {
if f.size < t.minSize {
return
}
t.mu.Lock()
defer t.mu.Unlock()
// Just append without sorting - sort only once at the end
t.entries = append(t.entries, f)
t.needsSort = true
// Update minimum size threshold dynamically
if len(t.entries) > maxLargeFiles*3 {
// Periodically sort and trim to avoid memory bloat
sort.Slice(t.entries, func(i, j int) bool {
return t.entries[i].size > t.entries[j].size
})
if len(t.entries) > maxLargeFiles {
t.minSize = t.entries[maxLargeFiles-1].size
t.entries = t.entries[:maxLargeFiles]
}
t.needsSort = false
}
}
func (t *largeFileTracker) list() []fileEntry {
t.mu.Lock()
defer t.mu.Unlock()
// Sort only when needed
if t.needsSort {
sort.Slice(t.entries, func(i, j int) bool {
return t.entries[i].size > t.entries[j].size
})
if len(t.entries) > maxLargeFiles {
t.entries = t.entries[:maxLargeFiles]
}
t.needsSort = false
}
return append([]fileEntry(nil), t.entries...)
}
func displayPath(path string) string { func displayPath(path string) string {
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
if err != nil || home == "" { if err != nil || home == "" {
@@ -1744,21 +1728,57 @@ func displayPath(path string) string {
// truncateMiddle truncates string in the middle, keeping head and tail // truncateMiddle truncates string in the middle, keeping head and tail
// e.g. "very/long/path/to/file.txt" -> "very/long/.../file.txt" // e.g. "very/long/path/to/file.txt" -> "very/long/.../file.txt"
func truncateMiddle(s string, maxLen int) string { // Handles UTF-8 and display width correctly (CJK chars count as 2 width)
if len(s) <= maxLen { func truncateMiddle(s string, maxWidth int) string {
runes := []rune(s)
currentWidth := displayWidth(s)
if currentWidth <= maxWidth {
return s return s
} }
// Reserve 3 chars for "..." // Reserve 3 width for "..."
if maxLen < 10 { if maxWidth < 10 {
return s[:maxLen] // Simple truncation for very small width
width := 0
for i, r := range runes {
width += runeWidth(r)
if width > maxWidth {
return string(runes[:i])
}
}
return s
} }
// Keep more of the tail (filename usually more important) // Keep more of the tail (filename usually more important)
headLen := (maxLen - 3) / 3 targetHeadWidth := (maxWidth - 3) / 3
tailLen := maxLen - 3 - headLen targetTailWidth := maxWidth - 3 - targetHeadWidth
return s[:headLen] + "..." + s[len(s)-tailLen:] // Find head cutoff point based on display width
headWidth := 0
headIdx := 0
for i, r := range runes {
w := runeWidth(r)
if headWidth + w > targetHeadWidth {
break
}
headWidth += w
headIdx = i + 1
}
// Find tail cutoff point based on display width
tailWidth := 0
tailIdx := len(runes)
for i := len(runes) - 1; i >= 0; i-- {
w := runeWidth(runes[i])
if tailWidth + w > targetTailWidth {
break
}
tailWidth += w
tailIdx = i
}
return string(runes[:headIdx]) + "..." + string(runes[tailIdx:])
} }
func formatNumber(n int64) string { func formatNumber(n int64) string {
@@ -2485,7 +2505,11 @@ func getLastAccessTime(path string) time.Time {
if err != nil { if err != nil {
return time.Time{} return time.Time{}
} }
return getLastAccessTimeFromInfo(info)
}
// getLastAccessTimeFromInfo extracts atime from existing FileInfo (faster, avoids re-stat)
func getLastAccessTimeFromInfo(info fs.FileInfo) time.Time {
// Use syscall to get atime on macOS // Use syscall to get atime on macOS
stat, ok := info.Sys().(*syscall.Stat_t) stat, ok := info.Sys().(*syscall.Stat_t)
if !ok { if !ok {