🧹 Dependency Scanner and Pruner
go
dependency
dependencies
1package main
2
3import (
4 "fmt"
5 "sort"
6 "strings"
7 "github.com/charmbracelet/bubbles/spinner"
8 tea "github.com/charmbracelet/bubbletea"
9 "github.com/charmbracelet/lipgloss"
10)
11
12type applicationPhase int
13
14const (
15 phaseScanning applicationPhase = iota
16 phaseBrowsing
17 phaseDeleting
18 phaseDone
19)
20
21type scanCompleteMessage struct {
22 dependencies []DependencyDirectory
23 scanError error
24}
25
26type deletionCompleteMessage struct {
27 freedBytes int64
28 deletedCount int
29}
30
31type applicationModel struct {
32 phase applicationPhase
33 rootPath string
34 dependencies []DependencyDirectory
35 selected map[int]bool
36 cursorPosition int
37 loadingSpinner spinner.Model
38 terminalWidth int
39 terminalHeight int
40 freedBytes int64
41 deletedCount int
42 scanError error
43}
44
45var (
46 accentStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
47 dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
48 sizeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("39"))
49 typeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("178"))
50)
51
52func initialModel(rootPath string) applicationModel {
53 loadingSpinner := spinner.New()
54 loadingSpinner.Spinner = spinner.Dot
55 loadingSpinner.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
56
57 return applicationModel{
58 phase: phaseScanning,
59 rootPath: rootPath,
60 selected: make(map[int]bool),
61 loadingSpinner: loadingSpinner,
62 }
63}
64
65func (model applicationModel) Init() tea.Cmd {
66 return tea.Batch(model.loadingSpinner.Tick, startScan(model.rootPath))
67}
68
69func startScan(rootPath string) tea.Cmd {
70 return func() tea.Msg {
71 dependencies, scanError := scanForDependencies(rootPath)
72
73 return scanCompleteMessage{dependencies: dependencies, scanError: scanError}
74 }
75}
76
77func startDeletion(dependencies []DependencyDirectory, selected map[int]bool) tea.Cmd {
78 return func() tea.Msg {
79 freedBytes, deletedCount := deleteDependencies(dependencies, selected)
80
81 return deletionCompleteMessage{freedBytes: freedBytes, deletedCount: deletedCount}
82 }
83}
84
85func (model applicationModel) Update(message tea.Msg) (tea.Model, tea.Cmd) {
86 switch typedMessage := message.(type) {
87 case tea.KeyMsg:
88 return model.handleKeyPress(typedMessage)
89 case tea.WindowSizeMsg:
90 model.terminalWidth = typedMessage.Width
91 model.terminalHeight = typedMessage.Height
92
93 return model, nil
94 case spinner.TickMsg:
95 var spinnerCommand tea.Cmd
96
97 model.loadingSpinner, spinnerCommand = model.loadingSpinner.Update(message)
98
99 return model, spinnerCommand
100 case scanCompleteMessage:
101 if typedMessage.scanError != nil {
102 model.scanError = typedMessage.scanError
103 model.phase = phaseDone
104
105 return model, tea.Quit
106 }
107
108 sort.Slice(typedMessage.dependencies, func(firstIndex, secondIndex int) bool {
109 return typedMessage.dependencies[firstIndex].SizeBytes > typedMessage.dependencies[secondIndex].SizeBytes
110 })
111
112 model.dependencies = typedMessage.dependencies
113 model.phase = phaseBrowsing
114
115 if len(model.dependencies) == 0 {
116 model.phase = phaseDone
117
118 return model, tea.Quit
119 }
120
121 return model, nil
122 case deletionCompleteMessage:
123 model.freedBytes = typedMessage.freedBytes
124 model.deletedCount = typedMessage.deletedCount
125 model.phase = phaseDone
126
127 return model, tea.Quit
128 }
129
130 return model, nil
131}
132
133func (model applicationModel) handleKeyPress(keyMessage tea.KeyMsg) (tea.Model, tea.Cmd) {
134 if keyMessage.String() == "ctrl+c" {
135 return model, tea.Quit
136 }
137
138 if model.phase != phaseBrowsing {
139 return model, nil
140 }
141
142 switch keyMessage.String() {
143 case "q":
144 return model, tea.Quit
145 case "up", "k":
146 if model.cursorPosition > 0 {
147 model.cursorPosition--
148 }
149 case "down", "j":
150 if model.cursorPosition < len(model.dependencies)-1 {
151 model.cursorPosition++
152 }
153 case " ":
154 if model.selected[model.cursorPosition] {
155 delete(model.selected, model.cursorPosition)
156 } else {
157 model.selected[model.cursorPosition] = true
158 }
159 case "a":
160 allCurrentlySelected := true
161
162 for dependencyIndex := range model.dependencies {
163 if !model.selected[dependencyIndex] {
164 allCurrentlySelected = false
165
166 break
167 }
168 }
169
170 if allCurrentlySelected {
171 model.selected = make(map[int]bool)
172 } else {
173 for dependencyIndex := range model.dependencies {
174 model.selected[dependencyIndex] = true
175 }
176 }
177 case "enter":
178 if len(model.selected) == 0 {
179 return model, nil
180 }
181
182 model.phase = phaseDeleting
183
184 return model, startDeletion(model.dependencies, model.selected)
185 }
186
187 return model, nil
188}
189
190func (model applicationModel) selectedSize() int64 {
191 var totalSize int64
192
193 for dependencyIndex, isSelected := range model.selected {
194 if isSelected {
195 totalSize += model.dependencies[dependencyIndex].SizeBytes
196 }
197 }
198
199 return totalSize
200}
201
202func (model applicationModel) View() string {
203 switch model.phase {
204 case phaseScanning:
205 return fmt.Sprintf("\n %s scanning %s\n", model.loadingSpinner.View(), model.rootPath)
206 case phaseBrowsing:
207 return model.viewBrowsing()
208 case phaseDeleting:
209 return fmt.Sprintf("\n %s deleting selected directories\n", model.loadingSpinner.View())
210 case phaseDone:
211 return model.viewDone()
212 }
213
214 return ""
215}
216
217func (model applicationModel) viewBrowsing() string {
218 var builder strings.Builder
219
220 builder.WriteString("\n ")
221 builder.WriteString(accentStyle.Render("deppa"))
222 builder.WriteString(dimStyle.Render(fmt.Sprintf(" — %d directories found", len(model.dependencies))))
223 builder.WriteString("\n\n")
224
225 visibleHeight := model.terminalHeight - 6
226
227 if visibleHeight < 1 {
228 visibleHeight = 10
229 }
230
231 startIndex := 0
232
233 if model.cursorPosition >= startIndex+visibleHeight {
234 startIndex = model.cursorPosition - visibleHeight + 1
235 }
236
237 endIndex := min(startIndex+visibleHeight, len(model.dependencies))
238
239 for displayIndex := startIndex; displayIndex < endIndex; displayIndex++ {
240 dependency := model.dependencies[displayIndex]
241 cursor := " "
242
243 if displayIndex == model.cursorPosition {
244 cursor = accentStyle.Bold(true).Render("> ")
245 }
246
247 checkbox := dimStyle.Render("[ ]")
248
249 if model.selected[displayIndex] {
250 checkbox = accentStyle.Render("[x]")
251 }
252
253 formattedSize := sizeStyle.Render(fmt.Sprintf("%9s", formatBytes(dependency.SizeBytes)))
254 formattedType := typeStyle.Render(fmt.Sprintf("%-14s", dependency.DirectoryType))
255
256 builder.WriteString(fmt.Sprintf(
257 "%s%s %s %s %s\n",
258 cursor, checkbox, formattedSize, formattedType, dimStyle.Render(dependency.RelativePath),
259 ))
260 }
261
262 builder.WriteString(dimStyle.Render(fmt.Sprintf(
263 "\n space toggle • a all • enter delete %s • q quit",
264 accentStyle.Render(formatBytes(model.selectedSize())),
265 )))
266
267 return builder.String()
268}
269
270func (model applicationModel) viewDone() string {
271 if model.scanError != nil {
272 return fmt.Sprintf("\n error: %s\n", model.scanError)
273 }
274
275 if len(model.dependencies) == 0 {
276 return "\n no dependency directories found\n"
277 }
278
279 return fmt.Sprintf(
280 "\n deleted %d directories, freed %s\n",
281 model.deletedCount, formatBytes(model.freedBytes),
282 )
283}
284
285func formatBytes(bytes int64) string {
286 const (
287 kilobyte = 1024
288 megabyte = 1024 * kilobyte
289 gigabyte = 1024 * megabyte
290 )
291
292 switch {
293 case bytes >= gigabyte:
294 return fmt.Sprintf("%.1f GB", float64(bytes)/float64(gigabyte))
295 case bytes >= megabyte:
296 return fmt.Sprintf("%.1f MB", float64(bytes)/float64(megabyte))
297 case bytes >= kilobyte:
298 return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kilobyte))
299 default:
300 return fmt.Sprintf("%d B", bytes)
301 }
302}