Skip to content

Commit 37beb8d

Browse files
committed
feat: Add NCDU-style hierarchical tree navigation
- Add TreeNode type in pkg/types/tree.go for hierarchical structure - Add ScanDirectory() method for lazy folder scanning - Add StateTree state with drill-down/back navigation (→/l, ←/h) - Add renderTreeView with breadcrumb, icons, and depth warnings - Add --tui flag to scan command for interactive TUI - Integrate Bubble Tea spinner and progress for tree scanning - All 11 tests pass
1 parent 7cd5516 commit 37beb8d

7 files changed

Lines changed: 656 additions & 31 deletions

File tree

cmd/root/scan.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66

77
"github.com/spf13/cobra"
88
"github.com/thanhdevapp/dev-cleaner/internal/scanner"
9+
"github.com/thanhdevapp/dev-cleaner/internal/tui"
910
"github.com/thanhdevapp/dev-cleaner/internal/ui"
1011
"github.com/thanhdevapp/dev-cleaner/pkg/types"
1112
)
@@ -15,6 +16,7 @@ var (
1516
scanAndroid bool
1617
scanNode bool
1718
scanAll bool
19+
scanTUI bool
1820
)
1921

2022
// scanCmd represents the scan command
@@ -41,6 +43,7 @@ func init() {
4143
scanCmd.Flags().BoolVar(&scanAndroid, "android", false, "Scan Android/Gradle artifacts only")
4244
scanCmd.Flags().BoolVar(&scanNode, "node", false, "Scan Node.js artifacts only")
4345
scanCmd.Flags().BoolVar(&scanAll, "all", true, "Scan all categories (default)")
46+
scanCmd.Flags().BoolVar(&scanTUI, "tui", false, "Launch interactive TUI for selection")
4447
}
4548

4649
func runScan(cmd *cobra.Command, args []string) {
@@ -83,6 +86,15 @@ func runScan(cmd *cobra.Command, args []string) {
8386
// Sort by size (largest first)
8487
sortBySize(results)
8588

89+
// Launch TUI if --tui flag is set
90+
if scanTUI {
91+
if err := tui.Run(results, false); err != nil {
92+
fmt.Fprintf(os.Stderr, "Error running TUI: %v\n", err)
93+
os.Exit(1)
94+
}
95+
return
96+
}
97+
8698
// Print results with enhanced UI
8799
ui.PrintResults(results)
88100
ui.PrintSummary(results)

dev-cleaner

120 KB
Binary file not shown.

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ require (
1212
require (
1313
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
1414
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
15+
github.com/charmbracelet/harmonica v0.2.0 // indirect
1516
github.com/charmbracelet/x/ansi v0.10.1 // indirect
1617
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
1718
github.com/charmbracelet/x/term v0.2.1 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlv
66
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
77
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
88
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
9+
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
10+
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
911
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
1012
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
1113
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=

internal/scanner/scanner.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
package scanner
33

44
import (
5+
"fmt"
56
"io/fs"
67
"os"
78
"path/filepath"
@@ -111,3 +112,85 @@ func (s *Scanner) PathExists(path string) bool {
111112
_, err := os.Stat(path)
112113
return err == nil
113114
}
115+
116+
// ScanDirectory scans a single directory lazily and returns TreeNode with children
117+
func (s *Scanner) ScanDirectory(path string, currentDepth int, maxDepth int) (*types.TreeNode, error) {
118+
// Depth limit check
119+
if currentDepth >= maxDepth {
120+
return nil, fmt.Errorf("max depth %d reached", maxDepth)
121+
}
122+
123+
// Read directory entries
124+
entries, err := os.ReadDir(path)
125+
if err != nil {
126+
return nil, fmt.Errorf("failed to read directory %s: %w", path, err)
127+
}
128+
129+
// Calculate total size
130+
totalSize, fileCount, _ := s.calculateSize(path)
131+
132+
// Build TreeNode
133+
node := &types.TreeNode{
134+
Path: path,
135+
Name: types.GetBasename(path),
136+
Size: totalSize,
137+
IsDir: true,
138+
Children: make([]*types.TreeNode, 0),
139+
Scanned: true,
140+
Depth: currentDepth,
141+
FileCount: fileCount,
142+
}
143+
144+
// Process children
145+
for _, entry := range entries {
146+
childPath := filepath.Join(path, entry.Name())
147+
148+
// Skip symlinks to avoid cycles
149+
info, err := entry.Info()
150+
if err != nil {
151+
continue
152+
}
153+
if info.Mode()&os.ModeSymlink != 0 {
154+
continue
155+
}
156+
157+
isDir := entry.IsDir()
158+
var childSize int64
159+
var childFileCount int
160+
161+
if isDir {
162+
// For directories, calculate size
163+
childSize, childFileCount, _ = s.calculateSize(childPath)
164+
} else {
165+
// For files, use file size
166+
childSize = info.Size()
167+
childFileCount = 1
168+
}
169+
170+
child := &types.TreeNode{
171+
Path: childPath,
172+
Name: entry.Name(),
173+
Size: childSize,
174+
IsDir: isDir,
175+
Scanned: false, // Lazy - not scanned yet
176+
Depth: currentDepth + 1,
177+
FileCount: childFileCount,
178+
}
179+
180+
node.AddChild(child)
181+
}
182+
183+
return node, nil
184+
}
185+
186+
// ScanResultToTreeNode converts ScanResult to initial TreeNode
187+
func (s *Scanner) ScanResultToTreeNode(result types.ScanResult) (*types.TreeNode, error) {
188+
node := types.ScanResultToTreeNode(result)
189+
190+
// Verify path exists
191+
if !s.PathExists(result.Path) {
192+
return nil, fmt.Errorf("path does not exist: %s", result.Path)
193+
}
194+
195+
return node, nil
196+
}

0 commit comments

Comments
 (0)