mirror of
https://github.com/tw93/Mole.git
synced 2026-02-11 17:38:59 +00:00
Significantly optimize the speed and cache of scanning
This commit is contained in:
BIN
bin/analyze-go
BIN
bin/analyze-go
Binary file not shown.
@@ -25,20 +25,20 @@ var (
|
|||||||
|
|
||||||
func snapshotFromModel(m model) historyEntry {
|
func snapshotFromModel(m model) historyEntry {
|
||||||
return historyEntry{
|
return historyEntry{
|
||||||
path: m.path,
|
Path: m.path,
|
||||||
entries: cloneDirEntries(m.entries),
|
Entries: cloneDirEntries(m.entries),
|
||||||
largeFiles: cloneFileEntries(m.largeFiles),
|
LargeFiles: cloneFileEntries(m.largeFiles),
|
||||||
totalSize: m.totalSize,
|
TotalSize: m.totalSize,
|
||||||
selected: m.selected,
|
Selected: m.selected,
|
||||||
entryOffset: m.offset,
|
EntryOffset: m.offset,
|
||||||
largeSelected: m.largeSelected,
|
LargeSelected: m.largeSelected,
|
||||||
largeOffset: m.largeOffset,
|
LargeOffset: m.largeOffset,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func cacheSnapshot(m model) historyEntry {
|
func cacheSnapshot(m model) historyEntry {
|
||||||
entry := snapshotFromModel(m)
|
entry := snapshotFromModel(m)
|
||||||
entry.dirty = false
|
entry.Dirty = false
|
||||||
return entry
|
return entry
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,7 +220,10 @@ func loadCacheFromDisk(path string) (*cacheEntry, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if info.ModTime().After(entry.ModTime) {
|
if info.ModTime().After(entry.ModTime) {
|
||||||
return nil, fmt.Errorf("cache expired: directory modified")
|
// Only expire cache if the directory has been newer for longer than the grace window.
|
||||||
|
if cacheModTimeGrace <= 0 || info.ModTime().Sub(entry.ModTime) > cacheModTimeGrace {
|
||||||
|
return nil, fmt.Errorf("cache expired: directory modified")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if time.Since(entry.ScanTime) > 7*24*time.Hour {
|
if time.Since(entry.ScanTime) > 7*24*time.Hour {
|
||||||
@@ -242,9 +245,9 @@ func saveCacheToDisk(path string, result scanResult) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
entry := cacheEntry{
|
entry := cacheEntry{
|
||||||
Entries: result.entries,
|
Entries: result.Entries,
|
||||||
LargeFiles: result.largeFiles,
|
LargeFiles: result.LargeFiles,
|
||||||
TotalSize: result.totalSize,
|
TotalSize: result.TotalSize,
|
||||||
ModTime: info.ModTime(),
|
ModTime: info.ModTime(),
|
||||||
ScanTime: time.Now(),
|
ScanTime: time.Now(),
|
||||||
}
|
}
|
||||||
@@ -258,3 +261,29 @@ func saveCacheToDisk(path string, result scanResult) error {
|
|||||||
encoder := gob.NewEncoder(file)
|
encoder := gob.NewEncoder(file)
|
||||||
return encoder.Encode(entry)
|
return encoder.Encode(entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func invalidateCache(path string) {
|
||||||
|
cachePath, err := getCachePath(path)
|
||||||
|
if err == nil {
|
||||||
|
_ = os.Remove(cachePath)
|
||||||
|
}
|
||||||
|
removeOverviewSnapshot(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeOverviewSnapshot(path string) {
|
||||||
|
if path == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
overviewSnapshotMu.Lock()
|
||||||
|
defer overviewSnapshotMu.Unlock()
|
||||||
|
if err := ensureOverviewSnapshotCacheLocked(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if overviewSnapshotCache == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := overviewSnapshotCache[path]; ok {
|
||||||
|
delete(overviewSnapshotCache, path)
|
||||||
|
_ = persistOverviewSnapshotLocked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,15 +13,15 @@ const (
|
|||||||
overviewCacheFile = "overview_sizes.json"
|
overviewCacheFile = "overview_sizes.json"
|
||||||
duTimeout = 60 * time.Second // Increased for large directories
|
duTimeout = 60 * time.Second // Increased for large directories
|
||||||
mdlsTimeout = 5 * time.Second
|
mdlsTimeout = 5 * time.Second
|
||||||
maxConcurrentOverview = 3 // Scan up to 3 overview dirs concurrently
|
maxConcurrentOverview = 3 // Scan up to 3 overview dirs concurrently
|
||||||
pathUpdateInterval = 500 // Update current path every N files
|
batchUpdateSize = 100 // Batch atomic updates every N items
|
||||||
batchUpdateSize = 100 // Batch atomic updates every N items
|
cacheModTimeGrace = 30 * time.Minute // Ignore minor directory mtime bumps
|
||||||
|
|
||||||
// Worker pool configuration
|
// Worker pool configuration
|
||||||
minWorkers = 16 // Minimum workers for better I/O throughput
|
minWorkers = 32 // Minimum workers for better I/O throughput
|
||||||
maxWorkers = 128 // Maximum workers to avoid excessive goroutines
|
maxWorkers = 256 // Maximum workers to avoid excessive goroutines
|
||||||
cpuMultiplier = 4 // Worker multiplier per CPU core for I/O-bound operations
|
cpuMultiplier = 8 // Worker multiplier per CPU core for I/O-bound operations
|
||||||
maxDirWorkers = 32 // Maximum concurrent subdirectory scans
|
maxDirWorkers = 64 // Maximum concurrent subdirectory scans
|
||||||
openCommandTimeout = 10 * time.Second // Timeout for open/reveal commands
|
openCommandTimeout = 10 * time.Second // Timeout for open/reveal commands
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -225,7 +225,6 @@ var spinnerFrames = []string{"|", "/", "-", "\\", "|", "/", "-", "\\"}
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
colorPurple = "\033[0;35m"
|
colorPurple = "\033[0;35m"
|
||||||
colorBlue = "\033[0;34m"
|
|
||||||
colorGray = "\033[0;90m"
|
colorGray = "\033[0;90m"
|
||||||
colorRed = "\033[0;31m"
|
colorRed = "\033[0;31m"
|
||||||
colorYellow = "\033[1;33m"
|
colorYellow = "\033[1;33m"
|
||||||
@@ -233,7 +232,4 @@ const (
|
|||||||
colorCyan = "\033[0;36m"
|
colorCyan = "\033[0;36m"
|
||||||
colorReset = "\033[0m"
|
colorReset = "\033[0m"
|
||||||
colorBold = "\033[1m"
|
colorBold = "\033[1m"
|
||||||
colorBgCyan = "\033[46m"
|
|
||||||
colorBgDark = "\033[100m"
|
|
||||||
colorInvert = "\033[7m"
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ func deletePathCmd(path string, counter *int64) tea.Cmd {
|
|||||||
done: true,
|
done: true,
|
||||||
err: err,
|
err: err,
|
||||||
count: count,
|
count: count,
|
||||||
|
path: path,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,21 +98,6 @@ func humanizeBytes(size int64) string {
|
|||||||
return fmt.Sprintf("%.1f %cB", value, "KMGTPE"[exp])
|
return fmt.Sprintf("%.1f %cB", value, "KMGTPE"[exp])
|
||||||
}
|
}
|
||||||
|
|
||||||
func progressBar(value, max int64) string {
|
|
||||||
if max <= 0 {
|
|
||||||
return strings.Repeat("░", barWidth)
|
|
||||||
}
|
|
||||||
filled := int((value * int64(barWidth)) / max)
|
|
||||||
if filled > barWidth {
|
|
||||||
filled = barWidth
|
|
||||||
}
|
|
||||||
bar := strings.Repeat("█", filled)
|
|
||||||
if filled < barWidth {
|
|
||||||
bar += strings.Repeat("░", barWidth-filled)
|
|
||||||
}
|
|
||||||
return bar
|
|
||||||
}
|
|
||||||
|
|
||||||
func coloredProgressBar(value, max int64, percent float64) string {
|
func coloredProgressBar(value, max int64, percent float64) string {
|
||||||
if max <= 0 {
|
if max <= 0 {
|
||||||
return colorGray + strings.Repeat("░", barWidth) + colorReset
|
return colorGray + strings.Repeat("░", barWidth) + colorReset
|
||||||
|
|||||||
@@ -17,23 +17,23 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type dirEntry struct {
|
type dirEntry struct {
|
||||||
name string
|
Name string
|
||||||
path string
|
Path string
|
||||||
size int64
|
Size int64
|
||||||
isDir bool
|
IsDir bool
|
||||||
lastAccess time.Time
|
LastAccess time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type fileEntry struct {
|
type fileEntry struct {
|
||||||
name string
|
Name string
|
||||||
path string
|
Path string
|
||||||
size int64
|
Size int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type scanResult struct {
|
type scanResult struct {
|
||||||
entries []dirEntry
|
Entries []dirEntry
|
||||||
largeFiles []fileEntry
|
LargeFiles []fileEntry
|
||||||
totalSize int64
|
TotalSize int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type cacheEntry struct {
|
type cacheEntry struct {
|
||||||
@@ -45,15 +45,15 @@ type cacheEntry struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type historyEntry struct {
|
type historyEntry struct {
|
||||||
path string
|
Path string
|
||||||
entries []dirEntry
|
Entries []dirEntry
|
||||||
largeFiles []fileEntry
|
LargeFiles []fileEntry
|
||||||
totalSize int64
|
TotalSize int64
|
||||||
selected int
|
Selected int
|
||||||
entryOffset int
|
EntryOffset int
|
||||||
largeSelected int
|
LargeSelected int
|
||||||
largeOffset int
|
LargeOffset int
|
||||||
dirty bool
|
Dirty bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type scanResultMsg struct {
|
type scanResultMsg struct {
|
||||||
@@ -62,10 +62,10 @@ type scanResultMsg struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type overviewSizeMsg struct {
|
type overviewSizeMsg struct {
|
||||||
path string
|
Path string
|
||||||
index int
|
Index int
|
||||||
size int64
|
Size int64
|
||||||
err error
|
Err error
|
||||||
}
|
}
|
||||||
|
|
||||||
type tickMsg time.Time
|
type tickMsg time.Time
|
||||||
@@ -74,6 +74,7 @@ type deleteProgressMsg struct {
|
|||||||
done bool
|
done bool
|
||||||
err error
|
err error
|
||||||
count int64
|
count int64
|
||||||
|
path string
|
||||||
}
|
}
|
||||||
|
|
||||||
type model struct {
|
type model struct {
|
||||||
@@ -109,6 +110,10 @@ type model struct {
|
|||||||
overviewScanningSet map[string]bool // Track which paths are currently being scanned
|
overviewScanningSet map[string]bool // Track which paths are currently being scanned
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m model) inOverviewMode() bool {
|
||||||
|
return m.isOverview && m.path == "/"
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
target := os.Getenv("MO_ANALYZE_PATH")
|
target := os.Getenv("MO_ANALYZE_PATH")
|
||||||
if target == "" && len(os.Args) > 1 {
|
if target == "" && len(os.Args) > 1 {
|
||||||
@@ -188,19 +193,19 @@ func createOverviewEntries() []dirEntry {
|
|||||||
|
|
||||||
if home != "" {
|
if home != "" {
|
||||||
entries = append(entries,
|
entries = append(entries,
|
||||||
dirEntry{name: "Home (~)", path: home, isDir: true, size: -1},
|
dirEntry{Name: "Home (~)", Path: home, IsDir: true, Size: -1},
|
||||||
dirEntry{name: "Library (~/Library)", path: filepath.Join(home, "Library"), isDir: true, size: -1},
|
dirEntry{Name: "Library (~/Library)", Path: filepath.Join(home, "Library"), IsDir: true, Size: -1},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
entries = append(entries,
|
entries = append(entries,
|
||||||
dirEntry{name: "Applications", path: "/Applications", isDir: true, size: -1},
|
dirEntry{Name: "Applications", Path: "/Applications", IsDir: true, Size: -1},
|
||||||
dirEntry{name: "System Library", path: "/Library", isDir: true, size: -1},
|
dirEntry{Name: "System Library", Path: "/Library", IsDir: true, Size: -1},
|
||||||
)
|
)
|
||||||
|
|
||||||
// Add Volumes shortcut only when it contains real mounted folders (e.g., external disks)
|
// Add Volumes shortcut only when it contains real mounted folders (e.g., external disks)
|
||||||
if hasUsefulVolumeMounts("/Volumes") {
|
if hasUsefulVolumeMounts("/Volumes") {
|
||||||
entries = append(entries, dirEntry{name: "Volumes", path: "/Volumes", isDir: true, size: -1})
|
entries = append(entries, dirEntry{Name: "Volumes", Path: "/Volumes", IsDir: true, Size: -1})
|
||||||
}
|
}
|
||||||
|
|
||||||
return entries
|
return entries
|
||||||
@@ -239,27 +244,27 @@ func (m *model) hydrateOverviewEntries() {
|
|||||||
m.overviewSizeCache = make(map[string]int64)
|
m.overviewSizeCache = make(map[string]int64)
|
||||||
}
|
}
|
||||||
for i := range m.entries {
|
for i := range m.entries {
|
||||||
if size, ok := m.overviewSizeCache[m.entries[i].path]; ok {
|
if size, ok := m.overviewSizeCache[m.entries[i].Path]; ok {
|
||||||
m.entries[i].size = size
|
m.entries[i].Size = size
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if size, err := loadOverviewCachedSize(m.entries[i].path); err == nil {
|
if size, err := loadOverviewCachedSize(m.entries[i].Path); err == nil {
|
||||||
m.entries[i].size = size
|
m.entries[i].Size = size
|
||||||
m.overviewSizeCache[m.entries[i].path] = size
|
m.overviewSizeCache[m.entries[i].Path] = size
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.totalSize = sumKnownEntrySizes(m.entries)
|
m.totalSize = sumKnownEntrySizes(m.entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *model) scheduleOverviewScans() tea.Cmd {
|
func (m *model) scheduleOverviewScans() tea.Cmd {
|
||||||
if !m.isOverview {
|
if !m.inOverviewMode() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find pending entries (not scanned and not currently scanning)
|
// Find pending entries (not scanned and not currently scanning)
|
||||||
var pendingIndices []int
|
var pendingIndices []int
|
||||||
for i, entry := range m.entries {
|
for i, entry := range m.entries {
|
||||||
if entry.size < 0 && !m.overviewScanningSet[entry.path] {
|
if entry.Size < 0 && !m.overviewScanningSet[entry.Path] {
|
||||||
pendingIndices = append(pendingIndices, i)
|
pendingIndices = append(pendingIndices, i)
|
||||||
if len(pendingIndices) >= maxConcurrentOverview {
|
if len(pendingIndices) >= maxConcurrentOverview {
|
||||||
break
|
break
|
||||||
@@ -280,22 +285,22 @@ func (m *model) scheduleOverviewScans() tea.Cmd {
|
|||||||
var cmds []tea.Cmd
|
var cmds []tea.Cmd
|
||||||
for _, idx := range pendingIndices {
|
for _, idx := range pendingIndices {
|
||||||
entry := m.entries[idx]
|
entry := m.entries[idx]
|
||||||
m.overviewScanningSet[entry.path] = true
|
m.overviewScanningSet[entry.Path] = true
|
||||||
cmd := scanOverviewPathCmd(entry.path, idx)
|
cmd := scanOverviewPathCmd(entry.Path, idx)
|
||||||
cmds = append(cmds, cmd)
|
cmds = append(cmds, cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.overviewScanning = true
|
m.overviewScanning = true
|
||||||
remaining := 0
|
remaining := 0
|
||||||
for _, e := range m.entries {
|
for _, e := range m.entries {
|
||||||
if e.size < 0 {
|
if e.Size < 0 {
|
||||||
remaining++
|
remaining++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(pendingIndices) > 0 {
|
if len(pendingIndices) > 0 {
|
||||||
firstEntry := m.entries[pendingIndices[0]]
|
firstEntry := m.entries[pendingIndices[0]]
|
||||||
if len(pendingIndices) == 1 {
|
if len(pendingIndices) == 1 {
|
||||||
m.status = fmt.Sprintf("Scanning %s... (%d left)", firstEntry.name, remaining)
|
m.status = fmt.Sprintf("Scanning %s... (%d left)", firstEntry.Name, remaining)
|
||||||
} else {
|
} else {
|
||||||
m.status = fmt.Sprintf("Scanning %d directories... (%d left)", len(pendingIndices), remaining)
|
m.status = fmt.Sprintf("Scanning %d directories... (%d left)", len(pendingIndices), remaining)
|
||||||
}
|
}
|
||||||
@@ -305,21 +310,6 @@ func (m *model) scheduleOverviewScans() tea.Cmd {
|
|||||||
return tea.Batch(cmds...)
|
return tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *model) updateScanProgress(files, dirs, bytes int64, path string) {
|
|
||||||
if m.filesScanned != nil {
|
|
||||||
atomic.StoreInt64(m.filesScanned, files)
|
|
||||||
}
|
|
||||||
if m.dirsScanned != nil {
|
|
||||||
atomic.StoreInt64(m.dirsScanned, dirs)
|
|
||||||
}
|
|
||||||
if m.bytesScanned != nil {
|
|
||||||
atomic.StoreInt64(m.bytesScanned, bytes)
|
|
||||||
}
|
|
||||||
if m.currentPath != nil && path != "" {
|
|
||||||
*m.currentPath = path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *model) getScanProgress() (files, dirs, bytes int64) {
|
func (m *model) getScanProgress() (files, dirs, bytes int64) {
|
||||||
if m.filesScanned != nil {
|
if m.filesScanned != nil {
|
||||||
files = atomic.LoadInt64(m.filesScanned)
|
files = atomic.LoadInt64(m.filesScanned)
|
||||||
@@ -333,21 +323,8 @@ func (m *model) getScanProgress() (files, dirs, bytes int64) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *model) getOverviewScanProgress() (files, dirs, bytes int64) {
|
|
||||||
if m.overviewFilesScanned != nil {
|
|
||||||
files = atomic.LoadInt64(m.overviewFilesScanned)
|
|
||||||
}
|
|
||||||
if m.overviewDirsScanned != nil {
|
|
||||||
dirs = atomic.LoadInt64(m.overviewDirsScanned)
|
|
||||||
}
|
|
||||||
if m.overviewBytesScanned != nil {
|
|
||||||
bytes = atomic.LoadInt64(m.overviewBytesScanned)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m model) Init() tea.Cmd {
|
func (m model) Init() tea.Cmd {
|
||||||
if m.isOverview {
|
if m.inOverviewMode() {
|
||||||
return m.scheduleOverviewScans()
|
return m.scheduleOverviewScans()
|
||||||
}
|
}
|
||||||
return tea.Batch(m.scanCmd(m.path), tickCmd())
|
return tea.Batch(m.scanCmd(m.path), tickCmd())
|
||||||
@@ -358,9 +335,9 @@ func (m model) scanCmd(path string) tea.Cmd {
|
|||||||
// Try to load from persistent cache first
|
// Try to load from persistent cache first
|
||||||
if cached, err := loadCacheFromDisk(path); err == nil {
|
if cached, err := loadCacheFromDisk(path); err == nil {
|
||||||
result := scanResult{
|
result := scanResult{
|
||||||
entries: cached.Entries,
|
Entries: cached.Entries,
|
||||||
largeFiles: cached.LargeFiles,
|
LargeFiles: cached.LargeFiles,
|
||||||
totalSize: cached.TotalSize,
|
TotalSize: cached.TotalSize,
|
||||||
}
|
}
|
||||||
return scanResultMsg{result: result, err: nil}
|
return scanResultMsg{result: result, err: nil}
|
||||||
}
|
}
|
||||||
@@ -405,14 +382,19 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.status = fmt.Sprintf("Failed to delete: %v", msg.err)
|
m.status = fmt.Sprintf("Failed to delete: %v", msg.err)
|
||||||
} else {
|
} else {
|
||||||
|
if msg.path != "" {
|
||||||
|
m.removePathFromView(msg.path)
|
||||||
|
invalidateCache(msg.path)
|
||||||
|
}
|
||||||
|
invalidateCache(m.path)
|
||||||
m.status = fmt.Sprintf("Deleted %d items", msg.count)
|
m.status = fmt.Sprintf("Deleted %d items", msg.count)
|
||||||
// Mark all caches as dirty
|
// Mark all caches as dirty
|
||||||
for i := range m.history {
|
for i := range m.history {
|
||||||
m.history[i].dirty = true
|
m.history[i].Dirty = true
|
||||||
}
|
}
|
||||||
for path := range m.cache {
|
for path := range m.cache {
|
||||||
entry := m.cache[path]
|
entry := m.cache[path]
|
||||||
entry.dirty = true
|
entry.Dirty = true
|
||||||
m.cache[path] = entry
|
m.cache[path] = entry
|
||||||
}
|
}
|
||||||
// Refresh the view
|
// Refresh the view
|
||||||
@@ -427,9 +409,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.status = fmt.Sprintf("Scan failed: %v", msg.err)
|
m.status = fmt.Sprintf("Scan failed: %v", msg.err)
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
m.entries = msg.result.entries
|
m.entries = msg.result.Entries
|
||||||
m.largeFiles = msg.result.largeFiles
|
m.largeFiles = msg.result.LargeFiles
|
||||||
m.totalSize = msg.result.totalSize
|
m.totalSize = msg.result.TotalSize
|
||||||
m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize))
|
m.status = fmt.Sprintf("Scanned %s", humanizeBytes(m.totalSize))
|
||||||
m.clampEntrySelection()
|
m.clampEntrySelection()
|
||||||
m.clampLargeSelection()
|
m.clampLargeSelection()
|
||||||
@@ -446,23 +428,23 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
case overviewSizeMsg:
|
case overviewSizeMsg:
|
||||||
// Remove from scanning set
|
// Remove from scanning set
|
||||||
delete(m.overviewScanningSet, msg.path)
|
delete(m.overviewScanningSet, msg.Path)
|
||||||
|
|
||||||
if msg.err == nil {
|
if msg.Err == nil {
|
||||||
if m.overviewSizeCache == nil {
|
if m.overviewSizeCache == nil {
|
||||||
m.overviewSizeCache = make(map[string]int64)
|
m.overviewSizeCache = make(map[string]int64)
|
||||||
}
|
}
|
||||||
m.overviewSizeCache[msg.path] = msg.size
|
m.overviewSizeCache[msg.Path] = msg.Size
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.isOverview {
|
if m.inOverviewMode() {
|
||||||
// Update entry with result
|
// Update entry with result
|
||||||
for i := range m.entries {
|
for i := range m.entries {
|
||||||
if m.entries[i].path == msg.path {
|
if m.entries[i].Path == msg.Path {
|
||||||
if msg.err == nil {
|
if msg.Err == nil {
|
||||||
m.entries[i].size = msg.size
|
m.entries[i].Size = msg.Size
|
||||||
} else {
|
} else {
|
||||||
m.entries[i].size = 0
|
m.entries[i].Size = 0
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -470,8 +452,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.totalSize = sumKnownEntrySizes(m.entries)
|
m.totalSize = sumKnownEntrySizes(m.entries)
|
||||||
|
|
||||||
// Show error briefly if any
|
// Show error briefly if any
|
||||||
if msg.err != nil {
|
if msg.Err != nil {
|
||||||
m.status = fmt.Sprintf("Unable to measure %s: %v", displayPath(msg.path), msg.err)
|
m.status = fmt.Sprintf("Unable to measure %s: %v", displayPath(msg.Path), msg.Err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schedule next batch of scans
|
// Schedule next batch of scans
|
||||||
@@ -482,15 +464,15 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case tickMsg:
|
case tickMsg:
|
||||||
// Keep spinner running if scanning or deleting or if there are pending overview items
|
// Keep spinner running if scanning or deleting or if there are pending overview items
|
||||||
hasPending := false
|
hasPending := false
|
||||||
if m.isOverview {
|
if m.inOverviewMode() {
|
||||||
for _, entry := range m.entries {
|
for _, entry := range m.entries {
|
||||||
if entry.size < 0 {
|
if entry.Size < 0 {
|
||||||
hasPending = true
|
hasPending = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if m.scanning || m.deleting || (m.isOverview && (m.overviewScanning || hasPending)) {
|
if m.scanning || m.deleting || (m.inOverviewMode() && (m.overviewScanning || hasPending)) {
|
||||||
m.spinner = (m.spinner + 1) % len(spinnerFrames)
|
m.spinner = (m.spinner + 1) % len(spinnerFrames)
|
||||||
// Update delete progress status
|
// Update delete progress status
|
||||||
if m.deleting && m.deleteCount != nil {
|
if m.deleting && m.deleteCount != nil {
|
||||||
@@ -517,8 +499,8 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.deleting = true
|
m.deleting = true
|
||||||
var deleteCount int64
|
var deleteCount int64
|
||||||
m.deleteCount = &deleteCount
|
m.deleteCount = &deleteCount
|
||||||
targetPath := m.deleteTarget.path
|
targetPath := m.deleteTarget.Path
|
||||||
targetName := m.deleteTarget.name
|
targetName := m.deleteTarget.Name
|
||||||
m.deleteTarget = nil
|
m.deleteTarget = nil
|
||||||
m.status = fmt.Sprintf("Deleting %s...", targetName)
|
m.status = fmt.Sprintf("Deleting %s...", targetName)
|
||||||
return m, tea.Batch(deletePathCmd(targetPath, m.deleteCount), tickCmd())
|
return m, tea.Batch(deletePathCmd(targetPath, m.deleteCount), tickCmd())
|
||||||
@@ -595,27 +577,27 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
if len(m.history) == 0 {
|
if len(m.history) == 0 {
|
||||||
// Return to overview if at top level
|
// Return to overview if at top level
|
||||||
if !m.isOverview {
|
if !m.inOverviewMode() {
|
||||||
return m, m.switchToOverviewMode()
|
return m, m.switchToOverviewMode()
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
last := m.history[len(m.history)-1]
|
last := m.history[len(m.history)-1]
|
||||||
m.history = m.history[:len(m.history)-1]
|
m.history = m.history[:len(m.history)-1]
|
||||||
m.path = last.path
|
m.path = last.Path
|
||||||
m.selected = last.selected
|
m.selected = last.Selected
|
||||||
m.offset = last.entryOffset
|
m.offset = last.EntryOffset
|
||||||
m.largeSelected = last.largeSelected
|
m.largeSelected = last.LargeSelected
|
||||||
m.largeOffset = last.largeOffset
|
m.largeOffset = last.LargeOffset
|
||||||
m.isOverview = false
|
m.isOverview = false
|
||||||
if last.dirty {
|
if last.Dirty {
|
||||||
m.status = "Scanning..."
|
m.status = "Scanning..."
|
||||||
m.scanning = true
|
m.scanning = true
|
||||||
return m, tea.Batch(m.scanCmd(m.path), tickCmd())
|
return m, tea.Batch(m.scanCmd(m.path), tickCmd())
|
||||||
}
|
}
|
||||||
m.entries = last.entries
|
m.entries = last.Entries
|
||||||
m.largeFiles = last.largeFiles
|
m.largeFiles = last.LargeFiles
|
||||||
m.totalSize = last.totalSize
|
m.totalSize = last.TotalSize
|
||||||
m.clampEntrySelection()
|
m.clampEntrySelection()
|
||||||
m.clampLargeSelection()
|
m.clampLargeSelection()
|
||||||
if len(m.entries) == 0 {
|
if len(m.entries) == 0 {
|
||||||
@@ -648,8 +630,8 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
_ = exec.CommandContext(ctx, "open", path).Run()
|
_ = exec.CommandContext(ctx, "open", path).Run()
|
||||||
}(selected.path)
|
}(selected.Path)
|
||||||
m.status = fmt.Sprintf("Opening %s...", selected.name)
|
m.status = fmt.Sprintf("Opening %s...", selected.Name)
|
||||||
}
|
}
|
||||||
} else if len(m.entries) > 0 {
|
} else if len(m.entries) > 0 {
|
||||||
selected := m.entries[m.selected]
|
selected := m.entries[m.selected]
|
||||||
@@ -657,8 +639,8 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
_ = exec.CommandContext(ctx, "open", path).Run()
|
_ = exec.CommandContext(ctx, "open", path).Run()
|
||||||
}(selected.path)
|
}(selected.Path)
|
||||||
m.status = fmt.Sprintf("Opening %s...", selected.name)
|
m.status = fmt.Sprintf("Opening %s...", selected.Name)
|
||||||
}
|
}
|
||||||
case "f", "F":
|
case "f", "F":
|
||||||
// Reveal selected entry in Finder
|
// Reveal selected entry in Finder
|
||||||
@@ -669,8 +651,8 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
_ = exec.CommandContext(ctx, "open", "-R", path).Run()
|
_ = exec.CommandContext(ctx, "open", "-R", path).Run()
|
||||||
}(selected.path)
|
}(selected.Path)
|
||||||
m.status = fmt.Sprintf("Revealing %s in Finder...", selected.name)
|
m.status = fmt.Sprintf("Revealing %s in Finder...", selected.Name)
|
||||||
}
|
}
|
||||||
} else if len(m.entries) > 0 {
|
} else if len(m.entries) > 0 {
|
||||||
selected := m.entries[m.selected]
|
selected := m.entries[m.selected]
|
||||||
@@ -678,8 +660,8 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), openCommandTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
_ = exec.CommandContext(ctx, "open", "-R", path).Run()
|
_ = exec.CommandContext(ctx, "open", "-R", path).Run()
|
||||||
}(selected.path)
|
}(selected.Path)
|
||||||
m.status = fmt.Sprintf("Revealing %s in Finder...", selected.name)
|
m.status = fmt.Sprintf("Revealing %s in Finder...", selected.Name)
|
||||||
}
|
}
|
||||||
case "delete", "backspace":
|
case "delete", "backspace":
|
||||||
// Delete selected file or directory
|
// Delete selected file or directory
|
||||||
@@ -688,13 +670,13 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
selected := m.largeFiles[m.largeSelected]
|
selected := m.largeFiles[m.largeSelected]
|
||||||
m.deleteConfirm = true
|
m.deleteConfirm = true
|
||||||
m.deleteTarget = &dirEntry{
|
m.deleteTarget = &dirEntry{
|
||||||
name: selected.name,
|
Name: selected.Name,
|
||||||
path: selected.path,
|
Path: selected.Path,
|
||||||
size: selected.size,
|
Size: selected.Size,
|
||||||
isDir: false,
|
IsDir: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if len(m.entries) > 0 && !m.isOverview {
|
} else if len(m.entries) > 0 && !m.inOverviewMode() {
|
||||||
selected := m.entries[m.selected]
|
selected := m.entries[m.selected]
|
||||||
m.deleteConfirm = true
|
m.deleteConfirm = true
|
||||||
m.deleteTarget = &selected
|
m.deleteTarget = &selected
|
||||||
@@ -730,11 +712,11 @@ func (m model) enterSelectedDir() (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
selected := m.entries[m.selected]
|
selected := m.entries[m.selected]
|
||||||
if selected.isDir {
|
if selected.IsDir {
|
||||||
if !m.isOverview {
|
if !m.inOverviewMode() {
|
||||||
m.history = append(m.history, snapshotFromModel(m))
|
m.history = append(m.history, snapshotFromModel(m))
|
||||||
}
|
}
|
||||||
m.path = selected.path
|
m.path = selected.Path
|
||||||
m.selected = 0
|
m.selected = 0
|
||||||
m.offset = 0
|
m.offset = 0
|
||||||
m.status = "Scanning..."
|
m.status = "Scanning..."
|
||||||
@@ -749,14 +731,14 @@ func (m model) enterSelectedDir() (tea.Model, tea.Cmd) {
|
|||||||
*m.currentPath = ""
|
*m.currentPath = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
if cached, ok := m.cache[m.path]; ok && !cached.dirty {
|
if cached, ok := m.cache[m.path]; ok && !cached.Dirty {
|
||||||
m.entries = cloneDirEntries(cached.entries)
|
m.entries = cloneDirEntries(cached.Entries)
|
||||||
m.largeFiles = cloneFileEntries(cached.largeFiles)
|
m.largeFiles = cloneFileEntries(cached.LargeFiles)
|
||||||
m.totalSize = cached.totalSize
|
m.totalSize = cached.TotalSize
|
||||||
m.selected = cached.selected
|
m.selected = cached.Selected
|
||||||
m.offset = cached.entryOffset
|
m.offset = cached.EntryOffset
|
||||||
m.largeSelected = cached.largeSelected
|
m.largeSelected = cached.LargeSelected
|
||||||
m.largeOffset = cached.largeOffset
|
m.largeOffset = cached.LargeOffset
|
||||||
m.clampEntrySelection()
|
m.clampEntrySelection()
|
||||||
m.clampLargeSelection()
|
m.clampLargeSelection()
|
||||||
m.status = fmt.Sprintf("Cached view for %s", displayPath(m.path))
|
m.status = fmt.Sprintf("Cached view for %s", displayPath(m.path))
|
||||||
@@ -765,7 +747,7 @@ func (m model) enterSelectedDir() (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return m, tea.Batch(m.scanCmd(m.path), tickCmd())
|
return m, tea.Batch(m.scanCmd(m.path), tickCmd())
|
||||||
}
|
}
|
||||||
m.status = fmt.Sprintf("File: %s (%s)", selected.name, humanizeBytes(selected.size))
|
m.status = fmt.Sprintf("File: %s (%s)", selected.Name, humanizeBytes(selected.Size))
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -773,19 +755,13 @@ func (m model) View() string {
|
|||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
fmt.Fprintln(&b)
|
fmt.Fprintln(&b)
|
||||||
|
|
||||||
if m.deleteConfirm && m.deleteTarget != nil {
|
if m.inOverviewMode() {
|
||||||
// Show delete confirmation prominently at the top
|
|
||||||
fmt.Fprintf(&b, "%sDelete: %s (%s)? Press Delete again to confirm, ESC to cancel%s\n",
|
|
||||||
colorRed, m.deleteTarget.name, humanizeBytes(m.deleteTarget.size), colorReset)
|
|
||||||
}
|
|
||||||
|
|
||||||
if m.isOverview {
|
|
||||||
fmt.Fprintf(&b, "%sAnalyze Disk%s\n", colorPurple, colorReset)
|
fmt.Fprintf(&b, "%sAnalyze Disk%s\n", colorPurple, colorReset)
|
||||||
if m.overviewScanning {
|
if m.overviewScanning {
|
||||||
// Check if we're in initial scan (all entries are pending)
|
// Check if we're in initial scan (all entries are pending)
|
||||||
allPending := true
|
allPending := true
|
||||||
for _, entry := range m.entries {
|
for _, entry := range m.entries {
|
||||||
if entry.size >= 0 {
|
if entry.Size >= 0 {
|
||||||
allPending = false
|
allPending = false
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -807,7 +783,7 @@ func (m model) View() string {
|
|||||||
// Check if there are still pending items
|
// Check if there are still pending items
|
||||||
hasPending := false
|
hasPending := false
|
||||||
for _, entry := range m.entries {
|
for _, entry := range m.entries {
|
||||||
if entry.size < 0 {
|
if entry.Size < 0 {
|
||||||
hasPending = true
|
hasPending = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -880,13 +856,13 @@ func (m model) View() string {
|
|||||||
}
|
}
|
||||||
maxLargeSize := int64(1)
|
maxLargeSize := int64(1)
|
||||||
for _, file := range m.largeFiles {
|
for _, file := range m.largeFiles {
|
||||||
if file.size > maxLargeSize {
|
if file.Size > maxLargeSize {
|
||||||
maxLargeSize = file.size
|
maxLargeSize = file.Size
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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, 35)
|
shortPath = truncateMiddle(shortPath, 35)
|
||||||
paddedPath := padName(shortPath, 35)
|
paddedPath := padName(shortPath, 35)
|
||||||
entryPrefix := " "
|
entryPrefix := " "
|
||||||
@@ -899,8 +875,8 @@ func (m model) View() string {
|
|||||||
sizeColor = colorCyan
|
sizeColor = colorCyan
|
||||||
numColor = colorCyan
|
numColor = colorCyan
|
||||||
}
|
}
|
||||||
size := humanizeBytes(file.size)
|
size := humanizeBytes(file.Size)
|
||||||
bar := coloredProgressBar(file.size, maxLargeSize, 0)
|
bar := coloredProgressBar(file.Size, maxLargeSize, 0)
|
||||||
fmt.Fprintf(&b, "%s%s%2d.%s %s | 📄 %s%s%s %s%10s%s\n",
|
fmt.Fprintf(&b, "%s%s%2d.%s %s | 📄 %s%s%s %s%10s%s\n",
|
||||||
entryPrefix, numColor, idx+1, colorReset, bar, nameColor, paddedPath, colorReset, sizeColor, size, colorReset)
|
entryPrefix, numColor, idx+1, colorReset, bar, nameColor, paddedPath, colorReset, sizeColor, size, colorReset)
|
||||||
}
|
}
|
||||||
@@ -909,17 +885,17 @@ func (m model) View() string {
|
|||||||
if len(m.entries) == 0 {
|
if len(m.entries) == 0 {
|
||||||
fmt.Fprintln(&b, " Empty directory")
|
fmt.Fprintln(&b, " Empty directory")
|
||||||
} else {
|
} else {
|
||||||
if m.isOverview {
|
if m.inOverviewMode() {
|
||||||
maxSize := int64(1)
|
maxSize := int64(1)
|
||||||
for _, entry := range m.entries {
|
for _, entry := range m.entries {
|
||||||
if entry.size > maxSize {
|
if entry.Size > maxSize {
|
||||||
maxSize = entry.size
|
maxSize = entry.Size
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
totalSize := m.totalSize
|
totalSize := m.totalSize
|
||||||
for idx, entry := range m.entries {
|
for idx, entry := range m.entries {
|
||||||
icon := "📁"
|
icon := "📁"
|
||||||
sizeVal := entry.size
|
sizeVal := entry.Size
|
||||||
barValue := sizeVal
|
barValue := sizeVal
|
||||||
if barValue < 0 {
|
if barValue < 0 {
|
||||||
barValue = 0
|
barValue = 0
|
||||||
@@ -953,7 +929,7 @@ func (m model) View() string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
entryPrefix := " "
|
entryPrefix := " "
|
||||||
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 := ""
|
numColor := ""
|
||||||
@@ -969,9 +945,9 @@ func (m model) View() string {
|
|||||||
|
|
||||||
// Add unused time label if applicable
|
// Add unused time label if applicable
|
||||||
// For overview mode, get access time on-demand if not set
|
// For overview mode, get access time on-demand if not set
|
||||||
lastAccess := entry.lastAccess
|
lastAccess := entry.LastAccess
|
||||||
if lastAccess.IsZero() && entry.path != "" {
|
if lastAccess.IsZero() && entry.Path != "" {
|
||||||
lastAccess = getLastAccessTime(entry.path)
|
lastAccess = getLastAccessTime(entry.Path)
|
||||||
}
|
}
|
||||||
unusedLabel := formatUnusedTime(lastAccess)
|
unusedLabel := formatUnusedTime(lastAccess)
|
||||||
if unusedLabel == "" {
|
if unusedLabel == "" {
|
||||||
@@ -989,8 +965,8 @@ func (m model) View() string {
|
|||||||
// Normal mode with sizes and progress bars
|
// Normal mode with sizes and progress bars
|
||||||
maxSize := int64(1)
|
maxSize := int64(1)
|
||||||
for _, entry := range m.entries {
|
for _, entry := range m.entries {
|
||||||
if entry.size > maxSize {
|
if entry.Size > maxSize {
|
||||||
maxSize = entry.size
|
maxSize = entry.Size
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1006,19 +982,19 @@ func (m model) View() string {
|
|||||||
for idx := start; idx < end; idx++ {
|
for idx := start; idx < end; idx++ {
|
||||||
entry := m.entries[idx]
|
entry := m.entries[idx]
|
||||||
icon := "📄"
|
icon := "📄"
|
||||||
if entry.isDir {
|
if entry.IsDir {
|
||||||
icon = "📁"
|
icon = "📁"
|
||||||
}
|
}
|
||||||
size := humanizeBytes(entry.size)
|
size := humanizeBytes(entry.Size)
|
||||||
name := trimName(entry.name)
|
name := trimName(entry.Name)
|
||||||
paddedName := padName(name, 28)
|
paddedName := padName(name, 28)
|
||||||
|
|
||||||
// Calculate percentage
|
// Calculate percentage
|
||||||
percent := float64(entry.size) / float64(m.totalSize) * 100
|
percent := float64(entry.Size) / float64(m.totalSize) * 100
|
||||||
percentStr := fmt.Sprintf("%5.1f%%", percent)
|
percentStr := fmt.Sprintf("%5.1f%%", percent)
|
||||||
|
|
||||||
// Get colored progress bar
|
// Get colored progress bar
|
||||||
bar := coloredProgressBar(entry.size, maxSize, percent)
|
bar := coloredProgressBar(entry.Size, maxSize, percent)
|
||||||
|
|
||||||
// Color the size based on magnitude
|
// Color the size based on magnitude
|
||||||
var sizeColor string
|
var sizeColor string
|
||||||
@@ -1048,7 +1024,7 @@ func (m model) View() string {
|
|||||||
displayIndex := idx + 1
|
displayIndex := idx + 1
|
||||||
|
|
||||||
// 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%s%2d.%s %s %s%s%s | %s %s%10s%s\n",
|
fmt.Fprintf(&b, "%s%s%2d.%s %s %s%s%s | %s %s%10s%s\n",
|
||||||
entryPrefix, numColor, displayIndex, colorReset, bar, percentColor, percentStr, colorReset,
|
entryPrefix, numColor, displayIndex, colorReset, bar, percentColor, percentStr, colorReset,
|
||||||
@@ -1065,7 +1041,7 @@ func (m model) View() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintln(&b)
|
fmt.Fprintln(&b)
|
||||||
if m.isOverview {
|
if m.inOverviewMode() {
|
||||||
fmt.Fprintf(&b, "%s↑/↓ Nav | Enter | 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↑/↓ Nav | O Open | F Reveal | ⌫ Delete | L Back | Q Quit%s\n", colorGray, colorReset)
|
fmt.Fprintf(&b, "%s↑/↓ Nav | O Open | F Reveal | ⌫ Delete | L Back | Q Quit%s\n", colorGray, colorReset)
|
||||||
@@ -1077,6 +1053,13 @@ func (m model) View() string {
|
|||||||
fmt.Fprintf(&b, "%s↑/↓/←/→ Nav | Enter | 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if m.deleteConfirm && m.deleteTarget != nil {
|
||||||
|
fmt.Fprintln(&b)
|
||||||
|
fmt.Fprintf(&b, "%sDelete:%s %s (%s) %sConfirm: Delete | Cancel: ESC%s\n",
|
||||||
|
colorRed, colorReset,
|
||||||
|
m.deleteTarget.Name, humanizeBytes(m.deleteTarget.Size),
|
||||||
|
colorGray, colorReset)
|
||||||
|
}
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1137,8 +1120,8 @@ func (m *model) clampLargeSelection() {
|
|||||||
func sumKnownEntrySizes(entries []dirEntry) int64 {
|
func sumKnownEntrySizes(entries []dirEntry) int64 {
|
||||||
var total int64
|
var total int64
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if entry.size > 0 {
|
if entry.Size > 0 {
|
||||||
total += entry.size
|
total += entry.Size
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return total
|
return total
|
||||||
@@ -1146,7 +1129,7 @@ func sumKnownEntrySizes(entries []dirEntry) int64 {
|
|||||||
|
|
||||||
func nextPendingOverviewIndex(entries []dirEntry) int {
|
func nextPendingOverviewIndex(entries []dirEntry) int {
|
||||||
for i, entry := range entries {
|
for i, entry := range entries {
|
||||||
if entry.size < 0 {
|
if entry.Size < 0 {
|
||||||
return i
|
return i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1155,21 +1138,55 @@ func nextPendingOverviewIndex(entries []dirEntry) int {
|
|||||||
|
|
||||||
func hasPendingOverviewEntries(entries []dirEntry) bool {
|
func hasPendingOverviewEntries(entries []dirEntry) bool {
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if entry.size < 0 {
|
if entry.Size < 0 {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *model) removePathFromView(path string) {
|
||||||
|
if path == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var removedSize int64
|
||||||
|
for i, entry := range m.entries {
|
||||||
|
if entry.Path == path {
|
||||||
|
if entry.Size > 0 {
|
||||||
|
removedSize = entry.Size
|
||||||
|
}
|
||||||
|
m.entries = append(m.entries[:i], m.entries[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < len(m.largeFiles); i++ {
|
||||||
|
if m.largeFiles[i].Path == path {
|
||||||
|
m.largeFiles = append(m.largeFiles[:i], m.largeFiles[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if removedSize > 0 {
|
||||||
|
if removedSize > m.totalSize {
|
||||||
|
m.totalSize = 0
|
||||||
|
} else {
|
||||||
|
m.totalSize -= removedSize
|
||||||
|
}
|
||||||
|
m.clampEntrySelection()
|
||||||
|
}
|
||||||
|
m.clampLargeSelection()
|
||||||
|
}
|
||||||
|
|
||||||
func scanOverviewPathCmd(path string, index int) tea.Cmd {
|
func scanOverviewPathCmd(path string, index int) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
size, err := measureOverviewSize(path)
|
size, err := measureOverviewSize(path)
|
||||||
return overviewSizeMsg{
|
return overviewSizeMsg{
|
||||||
path: path,
|
Path: path,
|
||||||
index: index,
|
Index: index,
|
||||||
size: size,
|
Size: size,
|
||||||
err: err,
|
Err: err,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,11 +99,11 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
|
|||||||
atomic.AddInt64(dirsScanned, 1)
|
atomic.AddInt64(dirsScanned, 1)
|
||||||
|
|
||||||
entryChan <- dirEntry{
|
entryChan <- dirEntry{
|
||||||
name: name,
|
Name: name,
|
||||||
path: path,
|
Path: path,
|
||||||
size: size,
|
Size: size,
|
||||||
isDir: true,
|
IsDir: true,
|
||||||
lastAccess: time.Time{}, // Lazy load when displayed
|
LastAccess: time.Time{}, // Lazy load when displayed
|
||||||
}
|
}
|
||||||
}(child.Name(), fullPath)
|
}(child.Name(), fullPath)
|
||||||
continue
|
continue
|
||||||
@@ -121,11 +121,11 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
|
|||||||
atomic.AddInt64(dirsScanned, 1)
|
atomic.AddInt64(dirsScanned, 1)
|
||||||
|
|
||||||
entryChan <- dirEntry{
|
entryChan <- dirEntry{
|
||||||
name: name,
|
Name: name,
|
||||||
path: path,
|
Path: path,
|
||||||
size: size,
|
Size: size,
|
||||||
isDir: true,
|
IsDir: true,
|
||||||
lastAccess: time.Time{}, // Lazy load when displayed
|
LastAccess: time.Time{}, // Lazy load when displayed
|
||||||
}
|
}
|
||||||
}(child.Name(), fullPath)
|
}(child.Name(), fullPath)
|
||||||
continue
|
continue
|
||||||
@@ -142,15 +142,15 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
|
|||||||
atomic.AddInt64(bytesScanned, size)
|
atomic.AddInt64(bytesScanned, size)
|
||||||
|
|
||||||
entryChan <- dirEntry{
|
entryChan <- dirEntry{
|
||||||
name: child.Name(),
|
Name: child.Name(),
|
||||||
path: fullPath,
|
Path: fullPath,
|
||||||
size: size,
|
Size: size,
|
||||||
isDir: false,
|
IsDir: false,
|
||||||
lastAccess: getLastAccessTimeFromInfo(info),
|
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) && size >= minLargeFileSize {
|
if !shouldSkipFileForLargeTracking(fullPath) && size >= minLargeFileSize {
|
||||||
largeFileChan <- fileEntry{name: child.Name(), path: fullPath, size: size}
|
largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +162,7 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
|
|||||||
collectorWg.Wait()
|
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
|
||||||
})
|
})
|
||||||
if len(entries) > maxEntries {
|
if len(entries) > maxEntries {
|
||||||
entries = entries[:maxEntries]
|
entries = entries[:maxEntries]
|
||||||
@@ -174,7 +174,7 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
|
|||||||
} else {
|
} else {
|
||||||
// Sort and trim large files collected from scanning
|
// Sort and trim large files collected from scanning
|
||||||
sort.Slice(largeFiles, func(i, j int) bool {
|
sort.Slice(largeFiles, func(i, j int) bool {
|
||||||
return largeFiles[i].size > largeFiles[j].size
|
return largeFiles[i].Size > largeFiles[j].Size
|
||||||
})
|
})
|
||||||
if len(largeFiles) > maxLargeFiles {
|
if len(largeFiles) > maxLargeFiles {
|
||||||
largeFiles = largeFiles[:maxLargeFiles]
|
largeFiles = largeFiles[:maxLargeFiles]
|
||||||
@@ -182,17 +182,12 @@ func scanPathConcurrent(root string, filesScanned, dirsScanned, bytesScanned *in
|
|||||||
}
|
}
|
||||||
|
|
||||||
return scanResult{
|
return scanResult{
|
||||||
entries: entries,
|
Entries: entries,
|
||||||
largeFiles: largeFiles,
|
LargeFiles: largeFiles,
|
||||||
totalSize: total,
|
TotalSize: total,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func shouldFoldDir(name string) bool {
|
|
||||||
return foldDirs[name]
|
|
||||||
}
|
|
||||||
|
|
||||||
// shouldFoldDirWithPath checks if a directory should be folded based on path context
|
|
||||||
func shouldFoldDirWithPath(name, path string) bool {
|
func shouldFoldDirWithPath(name, path string) bool {
|
||||||
// Check basic fold list first
|
// Check basic fold list first
|
||||||
if foldDirs[name] {
|
if foldDirs[name] {
|
||||||
@@ -217,7 +212,6 @@ func shouldFoldDirWithPath(name, path string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func shouldSkipFileForLargeTracking(path string) bool {
|
func shouldSkipFileForLargeTracking(path string) bool {
|
||||||
ext := strings.ToLower(filepath.Ext(path))
|
ext := strings.ToLower(filepath.Ext(path))
|
||||||
return skipExtensions[ext]
|
return skipExtensions[ext]
|
||||||
@@ -338,15 +332,15 @@ func findLargeFilesWithSpotlight(root string, minSize int64) []fileEntry {
|
|||||||
// 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{
|
||||||
name: filepath.Base(line),
|
Name: filepath.Base(line),
|
||||||
path: line,
|
Path: line,
|
||||||
size: actualSize,
|
Size: actualSize,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by size (descending)
|
// Sort by size (descending)
|
||||||
sort.Slice(files, func(i, j int) bool {
|
sort.Slice(files, func(i, j int) bool {
|
||||||
return files[i].size > files[j].size
|
return files[i].Size > files[j].Size
|
||||||
})
|
})
|
||||||
|
|
||||||
// Return top N
|
// Return top N
|
||||||
@@ -433,7 +427,7 @@ func calculateDirSizeConcurrent(root string, largeFileChan chan<- fileEntry, fil
|
|||||||
|
|
||||||
// Track large files
|
// Track large files
|
||||||
if !shouldSkipFileForLargeTracking(fullPath) && size >= minLargeFileSize {
|
if !shouldSkipFileForLargeTracking(fullPath) && size >= minLargeFileSize {
|
||||||
largeFileChan <- fileEntry{name: child.Name(), path: fullPath, size: size}
|
largeFileChan <- fileEntry{Name: child.Name(), Path: fullPath, Size: size}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update current path
|
// Update current path
|
||||||
@@ -483,7 +477,6 @@ func measureOverviewSize(path string) (int64, error) {
|
|||||||
return 0, fmt.Errorf("unable to measure directory size with fast methods")
|
return 0, fmt.Errorf("unable to measure directory size with fast methods")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func getDirectorySizeFromDu(path string) (int64, error) {
|
func getDirectorySizeFromDu(path string) (int64, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), duTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), duTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
Reference in New Issue
Block a user