🧹 Dependency Scanner and Pruner
go dependency dependencies
at main 302 lines 7.4 kB view raw
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}