feat(cli): implement interactive purge TUI and update command
- commit
- b0d67b5131f1be69fadcbcebceabff88a1544725
- author
- brice · · 2025-12-22 06:12 UTC
- parent
- 22821bc
- signature
- unsigned
5 files · +590 -308
A internal/cli/cmd/format.go +16 -0
| @@ -0,0 +1,16 @@ | |||
| 1 | + | package cmd | |
| 2 | + | ||
| 3 | + | import "fmt" | |
| 4 | + | ||
| 5 | + | func formatSize(bytes int64) string { | |
| 6 | + | const unit = 1024 | |
| 7 | + | if bytes < unit { | |
| 8 | + | return fmt.Sprintf("%d B", bytes) | |
| 9 | + | } | |
| 10 | + | div, exp := int64(unit), 0 | |
| 11 | + | for n := bytes / unit; n >= unit; n /= unit { | |
| 12 | + | div *= unit | |
| 13 | + | exp++ | |
| 14 | + | } | |
| 15 | + | return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) | |
| 16 | + | } | |
| @@ -0,0 +1,16 @@ | |||||
| 1 | + | package cmd | |||
| 2 | + | ||||
| 3 | + | import "fmt" | |||
| 4 | + | ||||
| 5 | + | func formatSize(bytes int64) string { | |||
| 6 | + | const unit = 1024 | |||
| 7 | + | if bytes < unit { | |||
| 8 | + | return fmt.Sprintf("%d B", bytes) | |||
| 9 | + | } | |||
| 10 | + | div, exp := int64(unit), 0 | |||
| 11 | + | for n := bytes / unit; n >= unit; n /= unit { | |||
| 12 | + | div *= unit | |||
| 13 | + | exp++ | |||
| 14 | + | } | |||
| 15 | + | return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) | |||
| 16 | + | } | |||
M internal/cli/cmd/purge.go +42 -308
| @@ -1,341 +1,75 @@ | |||
| 1 | 1 | package cmd | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "bufio" | |
| 5 | - | "context" | |
| 6 | 4 | "fmt" | |
| 7 | - | "os" | |
| 8 | - | "path/filepath" | |
| 9 | - | "strings" | |
| 10 | 5 | ||
| 6 | + | tea "github.com/charmbracelet/bubbletea" | |
| 11 | 7 | "github.com/spf13/cobra" | |
| 12 | 8 | ||
| 13 | 9 | "github.com/bnema/dumber/internal/application/usecase" | |
| 10 | + | "github.com/bnema/dumber/internal/cli" | |
| 11 | + | "github.com/bnema/dumber/internal/cli/model" | |
| 12 | + | "github.com/bnema/dumber/internal/cli/styles" | |
| 14 | 13 | "github.com/bnema/dumber/internal/infrastructure/desktop" | |
| 14 | + | "github.com/bnema/dumber/internal/infrastructure/filesystem" | |
| 15 | + | xdgadapter "github.com/bnema/dumber/internal/infrastructure/xdg" | |
| 15 | 16 | ) | |
| 16 | 17 | ||
| 17 | - | var ( | |
| 18 | - | purgeForce bool | |
| 19 | - | purgeAll bool | |
| 20 | - | purgeDesktop bool | |
| 21 | - | purgeFilters bool | |
| 22 | - | ) | |
| 23 | - | ||
| 24 | - | const dumberAppName = "dumber" | |
| 25 | - | ||
| 26 | - | type purgeDir struct { | |
| 27 | - | path string | |
| 28 | - | desc string | |
| 29 | - | } | |
| 30 | - | ||
| 31 | - | type purgeDirWithSize struct { | |
| 32 | - | path string | |
| 33 | - | desc string | |
| 34 | - | size int64 | |
| 35 | - | } | |
| 36 | - | ||
| 37 | - | type purgeDesktopStatus struct { | |
| 38 | - | desktopInstalled bool | |
| 39 | - | iconInstalled bool | |
| 40 | - | isDefault bool | |
| 41 | - | desktopPath string | |
| 42 | - | iconPath string | |
| 43 | - | } | |
| 18 | + | var purgeForce bool | |
| 44 | 19 | ||
| 45 | 20 | var purgeCmd = &cobra.Command{ | |
| 46 | 21 | Use: "purge", | |
| 47 | - | Short: "Remove all dumber data and configuration", | |
| 48 | - | Long: `Remove all dumber XDG directories (config, data, state, cache). | |
| 49 | - | ||
| 50 | - | This will delete: | |
| 51 | - | - ~/.config/dumber/ (configuration) | |
| 52 | - | - ~/.local/share/dumber/ (data, database) | |
| 53 | - | - ~/.local/state/dumber/ (state, logs) | |
| 54 | - | - ~/.cache/dumber/ (cache) | |
| 55 | - | ||
| 56 | - | Use --force to skip confirmation prompt. | |
| 57 | - | Use --desktop to also remove the desktop file from ~/.local/share/applications/ | |
| 58 | - | Use --filters to only remove content filter cache (for re-downloading filters)`, | |
| 22 | + | Short: "Remove dumber data and configuration", | |
| 23 | + | Long: `Interactively select and remove dumber data directories and files. | |
| 24 | + | ||
| 25 | + | This can remove: | |
| 26 | + | - Config directory | |
| 27 | + | - Data directory | |
| 28 | + | - State directory | |
| 29 | + | - Cache directory | |
| 30 | + | - Content filter caches | |
| 31 | + | - Desktop integration files | |
| 32 | + | ||
| 33 | + | Use --force to remove everything without prompting.`, | |
| 59 | 34 | RunE: runPurge, | |
| 60 | 35 | } | |
| 61 | 36 | ||
| 62 | 37 | func init() { | |
| 63 | 38 | rootCmd.AddCommand(purgeCmd) | |
| 64 | - | purgeCmd.Flags().BoolVarP(&purgeForce, "force", "f", false, "skip confirmation prompt") | |
| 65 | - | purgeCmd.Flags().BoolVar(&purgeAll, "all", true, "remove all directories (default)") | |
| 66 | - | purgeCmd.Flags().BoolVar(&purgeDesktop, "desktop", false, "also remove desktop file") | |
| 67 | - | purgeCmd.Flags().BoolVar(&purgeFilters, "filters", false, "only remove content filter cache") | |
| 39 | + | purgeCmd.Flags().BoolVarP(&purgeForce, "force", "f", false, "remove all items without prompting") | |
| 68 | 40 | } | |
| 69 | 41 | ||
| 70 | 42 | func runPurge(_ *cobra.Command, _ []string) error { | |
| 71 | - | home, err := os.UserHomeDir() | |
| 72 | - | if err != nil { | |
| 73 | - | return fmt.Errorf("get home dir: %w", err) | |
| 74 | - | } | |
| 75 | - | ||
| 76 | - | // Handle --filters flag (only purge filter cache) | |
| 77 | - | if purgeFilters { | |
| 78 | - | return runPurgeFilters(home) | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | dirs := purgeDirList(home) | |
| 82 | - | existing := collectExistingDirs(dirs) | |
| 83 | - | ||
| 84 | - | var desktopStatus *purgeDesktopStatus | |
| 85 | - | // Check desktop integration status if --desktop flag is set | |
| 86 | - | if purgeDesktop { | |
| 87 | - | desktopStatus = getDesktopStatus() | |
| 88 | - | } | |
| 89 | - | ||
| 90 | - | // Check if there's anything to remove | |
| 91 | - | if len(existing) == 0 && desktopStatus == nil { | |
| 92 | - | fmt.Println("No dumber directories found.") | |
| 93 | - | return nil | |
| 94 | - | } | |
| 95 | - | ||
| 96 | - | describePurgeTargets(existing, desktopStatus) | |
| 97 | - | ||
| 98 | - | // Confirm unless --force | |
| 99 | - | if !confirmPurge() { | |
| 100 | - | fmt.Println("Aborted.") | |
| 101 | - | return nil | |
| 102 | - | } | |
| 103 | - | ||
| 104 | - | // Remove directories | |
| 105 | - | errors := removeDirs(existing) | |
| 106 | - | ||
| 107 | - | // Remove desktop integration | |
| 108 | - | if desktopStatus != nil { | |
| 109 | - | errors = removeDesktopIntegration(errors) | |
| 110 | - | } | |
| 111 | - | ||
| 112 | - | if len(errors) > 0 { | |
| 113 | - | fmt.Println() | |
| 114 | - | fmt.Println("Errors:") | |
| 115 | - | for _, e := range errors { | |
| 116 | - | fmt.Printf(" \u2717 %s\n", e) | |
| 117 | - | } | |
| 118 | - | return fmt.Errorf("failed to remove some items") | |
| 43 | + | app := GetApp() | |
| 44 | + | if app == nil { | |
| 45 | + | return fmt.Errorf("app not initialized") | |
| 119 | 46 | } | |
| 120 | 47 | ||
| 121 | - | fmt.Println() | |
| 122 | - | fmt.Println("Purge complete.") | |
| 123 | - | return nil | |
| 124 | - | } | |
| 125 | - | ||
| 126 | - | func purgeDirList(home string) []purgeDir { | |
| 127 | - | return []purgeDir{ | |
| 128 | - | {getXDGPath("XDG_CONFIG_HOME", home, ".config"), "config"}, | |
| 129 | - | {getXDGPath("XDG_DATA_HOME", home, ".local/share"), "data"}, | |
| 130 | - | {getXDGPath("XDG_STATE_HOME", home, ".local/state"), "state"}, | |
| 131 | - | {getXDGPath("XDG_CACHE_HOME", home, ".cache"), "cache"}, | |
| 132 | - | } | |
| 133 | - | } | |
| 134 | - | ||
| 135 | - | func collectExistingDirs(dirs []purgeDir) []purgeDirWithSize { | |
| 136 | - | var existing []purgeDirWithSize | |
| 137 | - | for _, d := range dirs { | |
| 138 | - | if info, err := os.Stat(d.path); err == nil && info.IsDir() { | |
| 139 | - | existing = append(existing, purgeDirWithSize{ | |
| 140 | - | path: d.path, | |
| 141 | - | desc: d.desc, | |
| 142 | - | size: getDirSize(d.path), | |
| 143 | - | }) | |
| 144 | - | } | |
| 145 | - | } | |
| 146 | - | return existing | |
| 147 | - | } | |
| 148 | - | ||
| 149 | - | func getDesktopStatus() *purgeDesktopStatus { | |
| 150 | - | adapter := desktop.New() | |
| 151 | - | status, err := adapter.GetStatus(context.Background()) | |
| 152 | - | if err != nil || (!status.DesktopFileInstalled && !status.IconInstalled) { | |
| 153 | - | return nil | |
| 154 | - | } | |
| 155 | - | return &purgeDesktopStatus{ | |
| 156 | - | desktopInstalled: status.DesktopFileInstalled, | |
| 157 | - | iconInstalled: status.IconInstalled, | |
| 158 | - | isDefault: status.IsDefaultBrowser, | |
| 159 | - | desktopPath: status.DesktopFilePath, | |
| 160 | - | iconPath: status.IconFilePath, | |
| 161 | - | } | |
| 162 | - | } | |
| 163 | - | ||
| 164 | - | func describePurgeTargets(existing []purgeDirWithSize, desktopStatus *purgeDesktopStatus) { | |
| 165 | - | fmt.Println("The following will be removed:") | |
| 166 | - | fmt.Println() | |
| 167 | - | for _, d := range existing { | |
| 168 | - | fmt.Printf(" \u2022 %s (%s, %s)\n", d.path, d.desc, formatSize(d.size)) | |
| 169 | - | } | |
| 170 | - | if desktopStatus != nil { | |
| 171 | - | if desktopStatus.desktopInstalled { | |
| 172 | - | fmt.Printf(" \u2022 %s (desktop file)\n", desktopStatus.desktopPath) | |
| 173 | - | } | |
| 174 | - | if desktopStatus.iconInstalled { | |
| 175 | - | fmt.Printf(" \u2022 %s (icon)\n", desktopStatus.iconPath) | |
| 176 | - | } | |
| 177 | - | if desktopStatus.isDefault { | |
| 178 | - | fmt.Println(" \u26a0 Dumber is currently the default browser") | |
| 179 | - | } | |
| 180 | - | } | |
| 181 | - | fmt.Println() | |
| 182 | - | } | |
| 48 | + | fsAdapter := filesystem.New() | |
| 49 | + | xdgAdapter := xdgadapter.New() | |
| 50 | + | desktopAdapter := desktop.New() | |
| 51 | + | purgeUC := usecase.NewPurgeDataUseCase(fsAdapter, xdgAdapter, desktopAdapter) | |
| 183 | 52 | ||
| 184 | - | func confirmPurge() bool { | |
| 185 | 53 | if purgeForce { | |
| 186 | - | return true | |
| 187 | - | } | |
| 188 | - | fmt.Print("Are you sure? [y/N]: ") | |
| 189 | - | reader := bufio.NewReader(os.Stdin) | |
| 190 | - | response, _ := reader.ReadString('\n') | |
| 191 | - | response = strings.TrimSpace(strings.ToLower(response)) | |
| 192 | - | return response == "y" || response == "yes" | |
| 193 | - | } | |
| 194 | - | ||
| 195 | - | func removeDirs(existing []purgeDirWithSize) []string { | |
| 196 | - | var errors []string | |
| 197 | - | for _, d := range existing { | |
| 198 | - | if err := os.RemoveAll(d.path); err != nil { | |
| 199 | - | errors = append(errors, fmt.Sprintf("%s: %v", d.path, err)) | |
| 200 | - | } else { | |
| 201 | - | fmt.Printf("\u2713 Removed %s\n", d.path) | |
| 202 | - | } | |
| 203 | - | } | |
| 204 | - | return errors | |
| 205 | - | } | |
| 206 | - | ||
| 207 | - | func removeDesktopIntegration(errors []string) []string { | |
| 208 | - | adapter := desktop.New() | |
| 209 | - | uc := usecase.NewRemoveDesktopUseCase(adapter) | |
| 210 | - | result, err := uc.Execute(context.Background()) | |
| 211 | - | if err != nil { | |
| 212 | - | return append(errors, fmt.Sprintf("desktop integration: %v", err)) | |
| 213 | - | } | |
| 214 | - | if result.WasDesktopInstalled { | |
| 215 | - | fmt.Printf("\u2713 Removed %s\n", result.RemovedDesktopPath) | |
| 54 | + | return runPurgeForce(app, purgeUC) | |
| 216 | 55 | } | |
| 217 | - | if result.WasIconInstalled { | |
| 218 | - | fmt.Printf("\u2713 Removed %s\n", result.RemovedIconPath) | |
| 219 | - | } | |
| 220 | - | if result.WasDefault { | |
| 221 | - | fmt.Println(" \u26a0 Was default browser - please set a new default") | |
| 222 | - | } | |
| 223 | - | return errors | |
| 224 | - | } | |
| 225 | - | ||
| 226 | - | // getXDGPath returns the XDG path for dumber. | |
| 227 | - | func getXDGPath(envVar, home, defaultSuffix string) string { | |
| 228 | - | base := os.Getenv(envVar) | |
| 229 | - | if base == "" { | |
| 230 | - | base = filepath.Join(home, defaultSuffix) | |
| 231 | - | } | |
| 232 | - | return filepath.Join(base, dumberAppName) | |
| 233 | - | } | |
| 234 | - | ||
| 235 | - | // getDirSize calculates the total size of a directory. | |
| 236 | - | func getDirSize(path string) int64 { | |
| 237 | - | var size int64 | |
| 238 | - | _ = filepath.Walk(path, func(_ string, info os.FileInfo, err error) error { | |
| 239 | - | if err != nil { | |
| 240 | - | return nil | |
| 241 | - | } | |
| 242 | - | if !info.IsDir() { | |
| 243 | - | size += info.Size() | |
| 244 | - | } | |
| 245 | - | return nil | |
| 246 | - | }) | |
| 247 | - | return size | |
| 248 | - | } | |
| 249 | 56 | ||
| 250 | - | // formatSize formats bytes as human-readable size. | |
| 251 | - | func formatSize(bytes int64) string { | |
| 252 | - | const unit = 1024 | |
| 253 | - | if bytes < unit { | |
| 254 | - | return fmt.Sprintf("%d B", bytes) | |
| 255 | - | } | |
| 256 | - | div, exp := int64(unit), 0 | |
| 257 | - | for n := bytes / unit; n >= unit; n /= unit { | |
| 258 | - | div *= unit | |
| 259 | - | exp++ | |
| 260 | - | } | |
| 261 | - | return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) | |
| 57 | + | m := model.NewPurgeModel(app.Theme, purgeUC) | |
| 58 | + | p := tea.NewProgram(m, tea.WithAltScreen()) | |
| 59 | + | _, err := p.Run() | |
| 60 | + | return err | |
| 262 | 61 | } | |
| 263 | 62 | ||
| 264 | - | // runPurgeFilters removes only the content filter cache. | |
| 265 | - | func runPurgeFilters(home string) error { | |
| 266 | - | dataDir := getXDGPath("XDG_DATA_HOME", home, ".local/share") | |
| 267 | - | filterDirs := []struct { | |
| 268 | - | path string | |
| 269 | - | desc string | |
| 270 | - | }{ | |
| 271 | - | {filepath.Join(dataDir, "filters", "json"), "filter JSON cache"}, | |
| 272 | - | {filepath.Join(dataDir, "filters", "store"), "compiled filters"}, | |
| 273 | - | } | |
| 274 | - | ||
| 275 | - | // Check which directories exist | |
| 276 | - | var existing []struct { | |
| 277 | - | path string | |
| 278 | - | desc string | |
| 279 | - | size int64 | |
| 280 | - | } | |
| 281 | - | ||
| 282 | - | for _, d := range filterDirs { | |
| 283 | - | if info, err := os.Stat(d.path); err == nil && info.IsDir() { | |
| 284 | - | size := getDirSize(d.path) | |
| 285 | - | existing = append(existing, struct { | |
| 286 | - | path string | |
| 287 | - | desc string | |
| 288 | - | size int64 | |
| 289 | - | }{d.path, d.desc, size}) | |
| 63 | + | func runPurgeForce(app *cli.App, purgeUC *usecase.PurgeDataUseCase) error { | |
| 64 | + | out, err := purgeUC.PurgeAll(app.Ctx()) | |
| 65 | + | if out != nil { | |
| 66 | + | for _, r := range out.Results { | |
| 67 | + | if r.Success { | |
| 68 | + | fmt.Printf("%s %s\n", app.Theme.SuccessStyle.Render(styles.IconCheck), r.Target.Path) | |
| 69 | + | } else { | |
| 70 | + | fmt.Printf("%s %s: %v\n", app.Theme.ErrorStyle.Render(styles.IconX), r.Target.Path, r.Error) | |
| 71 | + | } | |
| 290 | 72 | } | |
| 291 | 73 | } | |
| 292 | - | ||
| 293 | - | if len(existing) == 0 { | |
| 294 | - | fmt.Println("No filter cache found.") | |
| 295 | - | return nil | |
| 296 | - | } | |
| 297 | - | ||
| 298 | - | // Show what will be deleted | |
| 299 | - | fmt.Println("The following filter cache will be removed:") | |
| 300 | - | fmt.Println() | |
| 301 | - | for _, d := range existing { | |
| 302 | - | fmt.Printf(" • %s (%s, %s)\n", d.path, d.desc, formatSize(d.size)) | |
| 303 | - | } | |
| 304 | - | fmt.Println() | |
| 305 | - | ||
| 306 | - | // Confirm unless --force | |
| 307 | - | if !purgeForce { | |
| 308 | - | fmt.Print("Are you sure? [y/N]: ") | |
| 309 | - | reader := bufio.NewReader(os.Stdin) | |
| 310 | - | response, _ := reader.ReadString('\n') | |
| 311 | - | response = strings.TrimSpace(strings.ToLower(response)) | |
| 312 | - | ||
| 313 | - | if response != "y" && response != "yes" { | |
| 314 | - | fmt.Println("Aborted.") | |
| 315 | - | return nil | |
| 316 | - | } | |
| 317 | - | } | |
| 318 | - | ||
| 319 | - | // Remove directories | |
| 320 | - | var errors []string | |
| 321 | - | for _, d := range existing { | |
| 322 | - | if err := os.RemoveAll(d.path); err != nil { | |
| 323 | - | errors = append(errors, fmt.Sprintf("%s: %v", d.path, err)) | |
| 324 | - | } else { | |
| 325 | - | fmt.Printf("✓ Removed %s\n", d.path) | |
| 326 | - | } | |
| 327 | - | } | |
| 328 | - | ||
| 329 | - | if len(errors) > 0 { | |
| 330 | - | fmt.Println() | |
| 331 | - | fmt.Println("Errors:") | |
| 332 | - | for _, e := range errors { | |
| 333 | - | fmt.Printf(" ✗ %s\n", e) | |
| 334 | - | } | |
| 335 | - | return fmt.Errorf("failed to remove some items") | |
| 336 | - | } | |
| 337 | - | ||
| 338 | - | fmt.Println() | |
| 339 | - | fmt.Println("Filter cache purged. Filters will be re-downloaded on next browser start.") | |
| 340 | - | return nil | |
| 74 | + | return err | |
| 341 | 75 | } | |
| @@ -1,341 +1,75 @@ | |||||
| 1 | package cmd | 1 | package cmd | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "bufio" | |||
| 5 | - | "context" | |||
| 6 | "fmt" | 4 | "fmt" | ||
| 7 | - | "os" | |||
| 8 | - | "path/filepath" | |||
| 9 | - | "strings" | |||
| 10 | 5 | ||||
| 6 | + | tea "github.com/charmbracelet/bubbletea" | |||
| 11 | "github.com/spf13/cobra" | 7 | "github.com/spf13/cobra" | ||
| 12 | 8 | ||||
| 13 | "github.com/bnema/dumber/internal/application/usecase" | 9 | "github.com/bnema/dumber/internal/application/usecase" | ||
| 10 | + | "github.com/bnema/dumber/internal/cli" | |||
| 11 | + | "github.com/bnema/dumber/internal/cli/model" | |||
| 12 | + | "github.com/bnema/dumber/internal/cli/styles" | |||
| 14 | "github.com/bnema/dumber/internal/infrastructure/desktop" | 13 | "github.com/bnema/dumber/internal/infrastructure/desktop" | ||
| 14 | + | "github.com/bnema/dumber/internal/infrastructure/filesystem" | |||
| 15 | + | xdgadapter "github.com/bnema/dumber/internal/infrastructure/xdg" | |||
| 15 | ) | 16 | ) | ||
| 16 | 17 | ||||
| 17 | - | var ( | 18 | + | var purgeForce bool |
| 18 | - | purgeForce bool | |||
| 19 | - | purgeAll bool | |||
| 20 | - | purgeDesktop bool | |||
| 21 | - | purgeFilters bool | |||
| 22 | - | ) | |||
| 23 | - | ||||
| 24 | - | const dumberAppName = "dumber" | |||
| 25 | - | ||||
| 26 | - | type purgeDir struct { | |||
| 27 | - | path string | |||
| 28 | - | desc string | |||
| 29 | - | } | |||
| 30 | - | ||||
| 31 | - | type purgeDirWithSize struct { | |||
| 32 | - | path string | |||
| 33 | - | desc string | |||
| 34 | - | size int64 | |||
| 35 | - | } | |||
| 36 | - | ||||
| 37 | - | type purgeDesktopStatus struct { | |||
| 38 | - | desktopInstalled bool | |||
| 39 | - | iconInstalled bool | |||
| 40 | - | isDefault bool | |||
| 41 | - | desktopPath string | |||
| 42 | - | iconPath string | |||
| 43 | - | } | |||
| 44 | 19 | ||||
| 45 | var purgeCmd = &cobra.Command{ | 20 | var purgeCmd = &cobra.Command{ | ||
| 46 | Use: "purge", | 21 | Use: "purge", | ||
| 47 | - | Short: "Remove all dumber data and configuration", | 22 | + | Short: "Remove dumber data and configuration", |
| 48 | - | Long: `Remove all dumber XDG directories (config, data, state, cache). | 23 | + | Long: `Interactively select and remove dumber data directories and files. |
| 49 | - | 24 | + | ||
| 50 | - | This will delete: | 25 | + | This can remove: |
| 51 | - | - ~/.config/dumber/ (configuration) | 26 | + | - Config directory |
| 52 | - | - ~/.local/share/dumber/ (data, database) | 27 | + | - Data directory |
| 53 | - | - ~/.local/state/dumber/ (state, logs) | 28 | + | - State directory |
| 54 | - | - ~/.cache/dumber/ (cache) | 29 | + | - Cache directory |
| 55 | - | 30 | + | - Content filter caches | |
| 56 | - | Use --force to skip confirmation prompt. | 31 | + | - Desktop integration files |
| 57 | - | Use --desktop to also remove the desktop file from ~/.local/share/applications/ | 32 | + | |
| 58 | - | Use --filters to only remove content filter cache (for re-downloading filters)`, | 33 | + | Use --force to remove everything without prompting.`, |
| 59 | RunE: runPurge, | 34 | RunE: runPurge, | ||
| 60 | } | 35 | } | ||
| 61 | 36 | ||||
| 62 | func init() { | 37 | func init() { | ||
| 63 | rootCmd.AddCommand(purgeCmd) | 38 | rootCmd.AddCommand(purgeCmd) | ||
| 64 | - | purgeCmd.Flags().BoolVarP(&purgeForce, "force", "f", false, "skip confirmation prompt") | 39 | + | purgeCmd.Flags().BoolVarP(&purgeForce, "force", "f", false, "remove all items without prompting") |
| 65 | - | purgeCmd.Flags().BoolVar(&purgeAll, "all", true, "remove all directories (default)") | |||
| 66 | - | purgeCmd.Flags().BoolVar(&purgeDesktop, "desktop", false, "also remove desktop file") | |||
| 67 | - | purgeCmd.Flags().BoolVar(&purgeFilters, "filters", false, "only remove content filter cache") | |||
| 68 | } | 40 | } | ||
| 69 | 41 | ||||
| 70 | func runPurge(_ *cobra.Command, _ []string) error { | 42 | func runPurge(_ *cobra.Command, _ []string) error { | ||
| 71 | - | home, err := os.UserHomeDir() | 43 | + | app := GetApp() |
| 72 | - | if err != nil { | 44 | + | if app == nil { |
| 73 | - | return fmt.Errorf("get home dir: %w", err) | 45 | + | return fmt.Errorf("app not initialized") |
| 74 | - | } | |||
| 75 | - | ||||
| 76 | - | // Handle --filters flag (only purge filter cache) | |||
| 77 | - | if purgeFilters { | |||
| 78 | - | return runPurgeFilters(home) | |||
| 79 | - | } | |||
| 80 | - | ||||
| 81 | - | dirs := purgeDirList(home) | |||
| 82 | - | existing := collectExistingDirs(dirs) | |||
| 83 | - | ||||
| 84 | - | var desktopStatus *purgeDesktopStatus | |||
| 85 | - | // Check desktop integration status if --desktop flag is set | |||
| 86 | - | if purgeDesktop { | |||
| 87 | - | desktopStatus = getDesktopStatus() | |||
| 88 | - | } | |||
| 89 | - | ||||
| 90 | - | // Check if there's anything to remove | |||
| 91 | - | if len(existing) == 0 && desktopStatus == nil { | |||
| 92 | - | fmt.Println("No dumber directories found.") | |||
| 93 | - | return nil | |||
| 94 | - | } | |||
| 95 | - | ||||
| 96 | - | describePurgeTargets(existing, desktopStatus) | |||
| 97 | - | ||||
| 98 | - | // Confirm unless --force | |||
| 99 | - | if !confirmPurge() { | |||
| 100 | - | fmt.Println("Aborted.") | |||
| 101 | - | return nil | |||
| 102 | - | } | |||
| 103 | - | ||||
| 104 | - | // Remove directories | |||
| 105 | - | errors := removeDirs(existing) | |||
| 106 | - | ||||
| 107 | - | // Remove desktop integration | |||
| 108 | - | if desktopStatus != nil { | |||
| 109 | - | errors = removeDesktopIntegration(errors) | |||
| 110 | - | } | |||
| 111 | - | ||||
| 112 | - | if len(errors) > 0 { | |||
| 113 | - | fmt.Println() | |||
| 114 | - | fmt.Println("Errors:") | |||
| 115 | - | for _, e := range errors { | |||
| 116 | - | fmt.Printf(" \u2717 %s\n", e) | |||
| 117 | - | } | |||
| 118 | - | return fmt.Errorf("failed to remove some items") | |||
| 119 | } | 46 | } | ||
| 120 | 47 | ||||
| 121 | - | fmt.Println() | 48 | + | fsAdapter := filesystem.New() |
| 122 | - | fmt.Println("Purge complete.") | 49 | + | xdgAdapter := xdgadapter.New() |
| 123 | - | return nil | 50 | + | desktopAdapter := desktop.New() |
| 124 | - | } | 51 | + | purgeUC := usecase.NewPurgeDataUseCase(fsAdapter, xdgAdapter, desktopAdapter) |
| 125 | - | ||||
| 126 | - | func purgeDirList(home string) []purgeDir { | |||
| 127 | - | return []purgeDir{ | |||
| 128 | - | {getXDGPath("XDG_CONFIG_HOME", home, ".config"), "config"}, | |||
| 129 | - | {getXDGPath("XDG_DATA_HOME", home, ".local/share"), "data"}, | |||
| 130 | - | {getXDGPath("XDG_STATE_HOME", home, ".local/state"), "state"}, | |||
| 131 | - | {getXDGPath("XDG_CACHE_HOME", home, ".cache"), "cache"}, | |||
| 132 | - | } | |||
| 133 | - | } | |||
| 134 | - | ||||
| 135 | - | func collectExistingDirs(dirs []purgeDir) []purgeDirWithSize { | |||
| 136 | - | var existing []purgeDirWithSize | |||
| 137 | - | for _, d := range dirs { | |||
| 138 | - | if info, err := os.Stat(d.path); err == nil && info.IsDir() { | |||
| 139 | - | existing = append(existing, purgeDirWithSize{ | |||
| 140 | - | path: d.path, | |||
| 141 | - | desc: d.desc, | |||
| 142 | - | size: getDirSize(d.path), | |||
| 143 | - | }) | |||
| 144 | - | } | |||
| 145 | - | } | |||
| 146 | - | return existing | |||
| 147 | - | } | |||
| 148 | - | ||||
| 149 | - | func getDesktopStatus() *purgeDesktopStatus { | |||
| 150 | - | adapter := desktop.New() | |||
| 151 | - | status, err := adapter.GetStatus(context.Background()) | |||
| 152 | - | if err != nil || (!status.DesktopFileInstalled && !status.IconInstalled) { | |||
| 153 | - | return nil | |||
| 154 | - | } | |||
| 155 | - | return &purgeDesktopStatus{ | |||
| 156 | - | desktopInstalled: status.DesktopFileInstalled, | |||
| 157 | - | iconInstalled: status.IconInstalled, | |||
| 158 | - | isDefault: status.IsDefaultBrowser, | |||
| 159 | - | desktopPath: status.DesktopFilePath, | |||
| 160 | - | iconPath: status.IconFilePath, | |||
| 161 | - | } | |||
| 162 | - | } | |||
| 163 | - | ||||
| 164 | - | func describePurgeTargets(existing []purgeDirWithSize, desktopStatus *purgeDesktopStatus) { | |||
| 165 | - | fmt.Println("The following will be removed:") | |||
| 166 | - | fmt.Println() | |||
| 167 | - | for _, d := range existing { | |||
| 168 | - | fmt.Printf(" \u2022 %s (%s, %s)\n", d.path, d.desc, formatSize(d.size)) | |||
| 169 | - | } | |||
| 170 | - | if desktopStatus != nil { | |||
| 171 | - | if desktopStatus.desktopInstalled { | |||
| 172 | - | fmt.Printf(" \u2022 %s (desktop file)\n", desktopStatus.desktopPath) | |||
| 173 | - | } | |||
| 174 | - | if desktopStatus.iconInstalled { | |||
| 175 | - | fmt.Printf(" \u2022 %s (icon)\n", desktopStatus.iconPath) | |||
| 176 | - | } | |||
| 177 | - | if desktopStatus.isDefault { | |||
| 178 | - | fmt.Println(" \u26a0 Dumber is currently the default browser") | |||
| 179 | - | } | |||
| 180 | - | } | |||
| 181 | - | fmt.Println() | |||
| 182 | - | } | |||
| 183 | 52 | ||||
| 184 | - | func confirmPurge() bool { | |||
| 185 | if purgeForce { | 53 | if purgeForce { | ||
| 186 | - | return true | 54 | + | return runPurgeForce(app, purgeUC) |
| 187 | - | } | |||
| 188 | - | fmt.Print("Are you sure? [y/N]: ") | |||
| 189 | - | reader := bufio.NewReader(os.Stdin) | |||
| 190 | - | response, _ := reader.ReadString('\n') | |||
| 191 | - | response = strings.TrimSpace(strings.ToLower(response)) | |||
| 192 | - | return response == "y" || response == "yes" | |||
| 193 | - | } | |||
| 194 | - | ||||
| 195 | - | func removeDirs(existing []purgeDirWithSize) []string { | |||
| 196 | - | var errors []string | |||
| 197 | - | for _, d := range existing { | |||
| 198 | - | if err := os.RemoveAll(d.path); err != nil { | |||
| 199 | - | errors = append(errors, fmt.Sprintf("%s: %v", d.path, err)) | |||
| 200 | - | } else { | |||
| 201 | - | fmt.Printf("\u2713 Removed %s\n", d.path) | |||
| 202 | - | } | |||
| 203 | - | } | |||
| 204 | - | return errors | |||
| 205 | - | } | |||
| 206 | - | ||||
| 207 | - | func removeDesktopIntegration(errors []string) []string { | |||
| 208 | - | adapter := desktop.New() | |||
| 209 | - | uc := usecase.NewRemoveDesktopUseCase(adapter) | |||
| 210 | - | result, err := uc.Execute(context.Background()) | |||
| 211 | - | if err != nil { | |||
| 212 | - | return append(errors, fmt.Sprintf("desktop integration: %v", err)) | |||
| 213 | - | } | |||
| 214 | - | if result.WasDesktopInstalled { | |||
| 215 | - | fmt.Printf("\u2713 Removed %s\n", result.RemovedDesktopPath) | |||
| 216 | } | 55 | } | ||
| 217 | - | if result.WasIconInstalled { | |||
| 218 | - | fmt.Printf("\u2713 Removed %s\n", result.RemovedIconPath) | |||
| 219 | - | } | |||
| 220 | - | if result.WasDefault { | |||
| 221 | - | fmt.Println(" \u26a0 Was default browser - please set a new default") | |||
| 222 | - | } | |||
| 223 | - | return errors | |||
| 224 | - | } | |||
| 225 | - | ||||
| 226 | - | // getXDGPath returns the XDG path for dumber. | |||
| 227 | - | func getXDGPath(envVar, home, defaultSuffix string) string { | |||
| 228 | - | base := os.Getenv(envVar) | |||
| 229 | - | if base == "" { | |||
| 230 | - | base = filepath.Join(home, defaultSuffix) | |||
| 231 | - | } | |||
| 232 | - | return filepath.Join(base, dumberAppName) | |||
| 233 | - | } | |||
| 234 | - | ||||
| 235 | - | // getDirSize calculates the total size of a directory. | |||
| 236 | - | func getDirSize(path string) int64 { | |||
| 237 | - | var size int64 | |||
| 238 | - | _ = filepath.Walk(path, func(_ string, info os.FileInfo, err error) error { | |||
| 239 | - | if err != nil { | |||
| 240 | - | return nil | |||
| 241 | - | } | |||
| 242 | - | if !info.IsDir() { | |||
| 243 | - | size += info.Size() | |||
| 244 | - | } | |||
| 245 | - | return nil | |||
| 246 | - | }) | |||
| 247 | - | return size | |||
| 248 | - | } | |||
| 249 | 56 | ||||
| 250 | - | // formatSize formats bytes as human-readable size. | 57 | + | m := model.NewPurgeModel(app.Theme, purgeUC) |
| 251 | - | func formatSize(bytes int64) string { | 58 | + | p := tea.NewProgram(m, tea.WithAltScreen()) |
| 252 | - | const unit = 1024 | 59 | + | _, err := p.Run() |
| 253 | - | if bytes < unit { | 60 | + | return err |
| 254 | - | return fmt.Sprintf("%d B", bytes) | |||
| 255 | - | } | |||
| 256 | - | div, exp := int64(unit), 0 | |||
| 257 | - | for n := bytes / unit; n >= unit; n /= unit { | |||
| 258 | - | div *= unit | |||
| 259 | - | exp++ | |||
| 260 | - | } | |||
| 261 | - | return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) | |||
| 262 | } | 61 | } | ||
| 263 | 62 | ||||
| 264 | - | // runPurgeFilters removes only the content filter cache. | 63 | + | func runPurgeForce(app *cli.App, purgeUC *usecase.PurgeDataUseCase) error { |
| 265 | - | func runPurgeFilters(home string) error { | 64 | + | out, err := purgeUC.PurgeAll(app.Ctx()) |
| 266 | - | dataDir := getXDGPath("XDG_DATA_HOME", home, ".local/share") | 65 | + | if out != nil { |
| 267 | - | filterDirs := []struct { | 66 | + | for _, r := range out.Results { |
| 268 | - | path string | 67 | + | if r.Success { |
| 269 | - | desc string | 68 | + | fmt.Printf("%s %s\n", app.Theme.SuccessStyle.Render(styles.IconCheck), r.Target.Path) |
| 270 | - | }{ | 69 | + | } else { |
| 271 | - | {filepath.Join(dataDir, "filters", "json"), "filter JSON cache"}, | 70 | + | fmt.Printf("%s %s: %v\n", app.Theme.ErrorStyle.Render(styles.IconX), r.Target.Path, r.Error) |
| 272 | - | {filepath.Join(dataDir, "filters", "store"), "compiled filters"}, | 71 | + | } |
| 273 | - | } | |||
| 274 | - | ||||
| 275 | - | // Check which directories exist | |||
| 276 | - | var existing []struct { | |||
| 277 | - | path string | |||
| 278 | - | desc string | |||
| 279 | - | size int64 | |||
| 280 | - | } | |||
| 281 | - | ||||
| 282 | - | for _, d := range filterDirs { | |||
| 283 | - | if info, err := os.Stat(d.path); err == nil && info.IsDir() { | |||
| 284 | - | size := getDirSize(d.path) | |||
| 285 | - | existing = append(existing, struct { | |||
| 286 | - | path string | |||
| 287 | - | desc string | |||
| 288 | - | size int64 | |||
| 289 | - | }{d.path, d.desc, size}) | |||
| 290 | } | 72 | } | ||
| 291 | } | 73 | } | ||
| 292 | - | 74 | + | return err | |
| 293 | - | if len(existing) == 0 { | |||
| 294 | - | fmt.Println("No filter cache found.") | |||
| 295 | - | return nil | |||
| 296 | - | } | |||
| 297 | - | ||||
| 298 | - | // Show what will be deleted | |||
| 299 | - | fmt.Println("The following filter cache will be removed:") | |||
| 300 | - | fmt.Println() | |||
| 301 | - | for _, d := range existing { | |||
| 302 | - | fmt.Printf(" • %s (%s, %s)\n", d.path, d.desc, formatSize(d.size)) | |||
| 303 | - | } | |||
| 304 | - | fmt.Println() | |||
| 305 | - | ||||
| 306 | - | // Confirm unless --force | |||
| 307 | - | if !purgeForce { | |||
| 308 | - | fmt.Print("Are you sure? [y/N]: ") | |||
| 309 | - | reader := bufio.NewReader(os.Stdin) | |||
| 310 | - | response, _ := reader.ReadString('\n') | |||
| 311 | - | response = strings.TrimSpace(strings.ToLower(response)) | |||
| 312 | - | ||||
| 313 | - | if response != "y" && response != "yes" { | |||
| 314 | - | fmt.Println("Aborted.") | |||
| 315 | - | return nil | |||
| 316 | - | } | |||
| 317 | - | } | |||
| 318 | - | ||||
| 319 | - | // Remove directories | |||
| 320 | - | var errors []string | |||
| 321 | - | for _, d := range existing { | |||
| 322 | - | if err := os.RemoveAll(d.path); err != nil { | |||
| 323 | - | errors = append(errors, fmt.Sprintf("%s: %v", d.path, err)) | |||
| 324 | - | } else { | |||
| 325 | - | fmt.Printf("✓ Removed %s\n", d.path) | |||
| 326 | - | } | |||
| 327 | - | } | |||
| 328 | - | ||||
| 329 | - | if len(errors) > 0 { | |||
| 330 | - | fmt.Println() | |||
| 331 | - | fmt.Println("Errors:") | |||
| 332 | - | for _, e := range errors { | |||
| 333 | - | fmt.Printf(" ✗ %s\n", e) | |||
| 334 | - | } | |||
| 335 | - | return fmt.Errorf("failed to remove some items") | |||
| 336 | - | } | |||
| 337 | - | ||||
| 338 | - | fmt.Println() | |||
| 339 | - | fmt.Println("Filter cache purged. Filters will be re-downloaded on next browser start.") | |||
| 340 | - | return nil | |||
| 341 | } | 75 | } | ||
A internal/cli/model/purge.go +182 -0
| @@ -0,0 +1,182 @@ | |||
| 1 | + | package model | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | ||
| 7 | + | tea "github.com/charmbracelet/bubbletea" | |
| 8 | + | "github.com/charmbracelet/lipgloss" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 11 | + | "github.com/bnema/dumber/internal/cli/styles" | |
| 12 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // PurgeModel wraps styles.PurgeModel for standalone CLI use. | |
| 16 | + | type PurgeModel struct { | |
| 17 | + | selector styles.PurgeModel | |
| 18 | + | purgeUC *usecase.PurgeDataUseCase | |
| 19 | + | ||
| 20 | + | loading bool | |
| 21 | + | purging bool | |
| 22 | + | done bool | |
| 23 | + | ||
| 24 | + | results *usecase.PurgeOutput | |
| 25 | + | info string | |
| 26 | + | err error | |
| 27 | + | ||
| 28 | + | theme *styles.Theme | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // NewPurgeModel creates a new purge command model. | |
| 32 | + | func NewPurgeModel(theme *styles.Theme, purgeUC *usecase.PurgeDataUseCase) PurgeModel { | |
| 33 | + | return PurgeModel{theme: theme, purgeUC: purgeUC, loading: true} | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | type purgeTargetsLoadedMsg struct { | |
| 37 | + | targets []entity.PurgeTarget | |
| 38 | + | err error | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | type purgeCompleteMsg struct { | |
| 42 | + | out *usecase.PurgeOutput | |
| 43 | + | err error | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // Init implements tea.Model. | |
| 47 | + | func (m PurgeModel) Init() tea.Cmd { | |
| 48 | + | return m.loadTargets() | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func (m PurgeModel) loadTargets() tea.Cmd { | |
| 52 | + | return func() tea.Msg { | |
| 53 | + | targets, err := m.purgeUC.GetPurgeTargets(context.Background()) | |
| 54 | + | return purgeTargetsLoadedMsg{targets: targets, err: err} | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // Update implements tea.Model. | |
| 59 | + | func (m PurgeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |
| 60 | + | switch msg := msg.(type) { | |
| 61 | + | case purgeTargetsLoadedMsg: | |
| 62 | + | m.loading = false | |
| 63 | + | if msg.err != nil { | |
| 64 | + | m.err = msg.err | |
| 65 | + | m.done = true | |
| 66 | + | return m, nil | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | m.selector = styles.NewPurge(m.theme, msg.targets) | |
| 70 | + | return m, nil | |
| 71 | + | ||
| 72 | + | case purgeCompleteMsg: | |
| 73 | + | m.purging = false | |
| 74 | + | m.done = true | |
| 75 | + | m.results = msg.out | |
| 76 | + | if msg.err != nil { | |
| 77 | + | m.err = msg.err | |
| 78 | + | } | |
| 79 | + | return m, nil | |
| 80 | + | ||
| 81 | + | case tea.KeyMsg: | |
| 82 | + | if m.done { | |
| 83 | + | return m, tea.Quit | |
| 84 | + | } | |
| 85 | + | if m.loading || m.purging { | |
| 86 | + | return m, nil | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | if m.loading || m.purging { | |
| 91 | + | return m, nil | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | selector, cmd := m.selector.Update(msg) | |
| 95 | + | m.selector = selector | |
| 96 | + | ||
| 97 | + | if m.selector.Done() { | |
| 98 | + | if m.selector.Canceled { | |
| 99 | + | return m, tea.Quit | |
| 100 | + | } | |
| 101 | + | if m.selector.Confirmed { | |
| 102 | + | targetTypes := m.selector.SelectedTypes() | |
| 103 | + | if len(targetTypes) == 0 { | |
| 104 | + | m.done = true | |
| 105 | + | m.info = "Nothing selected" | |
| 106 | + | return m, nil | |
| 107 | + | } | |
| 108 | + | m.purging = true | |
| 109 | + | return m, m.performPurge(targetTypes) | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | return m, cmd | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func (m PurgeModel) performPurge(targetTypes []entity.PurgeTargetType) tea.Cmd { | |
| 117 | + | return func() tea.Msg { | |
| 118 | + | out, err := m.purgeUC.Execute(context.Background(), usecase.PurgeInput{TargetTypes: targetTypes}) | |
| 119 | + | return purgeCompleteMsg{out: out, err: err} | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // View implements tea.Model. | |
| 124 | + | func (m PurgeModel) View() string { | |
| 125 | + | t := m.theme | |
| 126 | + | ||
| 127 | + | if m.loading { | |
| 128 | + | spinner := styles.NewLoading(t, "Scanning purge targets...") | |
| 129 | + | return t.Box.Render(spinner.View()) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | if m.purging { | |
| 133 | + | spinner := styles.NewLoading(t, "Purging...") | |
| 134 | + | return t.Box.Render(spinner.View()) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | if m.done { | |
| 138 | + | if m.info != "" { | |
| 139 | + | content := lipgloss.JoinVertical( | |
| 140 | + | lipgloss.Left, | |
| 141 | + | t.Subtle.Render(m.info), | |
| 142 | + | "", | |
| 143 | + | t.Subtle.Render("Press any key to exit"), | |
| 144 | + | ) | |
| 145 | + | return t.Box.Render(content) | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | if m.err != nil && m.results == nil { | |
| 149 | + | content := lipgloss.JoinVertical( | |
| 150 | + | lipgloss.Left, | |
| 151 | + | t.ErrorStyle.Render("Error: "+m.err.Error()), | |
| 152 | + | "", | |
| 153 | + | t.Subtle.Render("Press any key to exit"), | |
| 154 | + | ) | |
| 155 | + | return t.Box.Render(content) | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | contentLines := []string{t.Title.Render("Purge complete")} | |
| 159 | + | if m.results != nil { | |
| 160 | + | for _, r := range m.results.Results { | |
| 161 | + | if r.Success { | |
| 162 | + | contentLines = append(contentLines, fmt.Sprintf("%s %s", t.SuccessStyle.Render(styles.IconCheck), r.Target.Path)) | |
| 163 | + | } else { | |
| 164 | + | contentLines = append(contentLines, fmt.Sprintf("%s %s: %v", t.ErrorStyle.Render(styles.IconX), r.Target.Path, r.Error)) | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | contentLines = append( | |
| 168 | + | contentLines, | |
| 169 | + | "", | |
| 170 | + | t.Subtle.Render( | |
| 171 | + | fmt.Sprintf("%d succeeded, %d failed", m.results.SuccessCount, m.results.FailureCount), | |
| 172 | + | ), | |
| 173 | + | ) | |
| 174 | + | } | |
| 175 | + | contentLines = append(contentLines, "", t.Subtle.Render("Press any key to exit")) | |
| 176 | + | return t.Box.Render(lipgloss.JoinVertical(lipgloss.Left, contentLines...)) | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | return m.selector.View() | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | var _ tea.Model = (*PurgeModel)(nil) | |
| @@ -0,0 +1,182 @@ | |||||
| 1 | + | package model | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | ||||
| 7 | + | tea "github.com/charmbracelet/bubbletea" | |||
| 8 | + | "github.com/charmbracelet/lipgloss" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/dumber/internal/application/usecase" | |||
| 11 | + | "github.com/bnema/dumber/internal/cli/styles" | |||
| 12 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // PurgeModel wraps styles.PurgeModel for standalone CLI use. | |||
| 16 | + | type PurgeModel struct { | |||
| 17 | + | selector styles.PurgeModel | |||
| 18 | + | purgeUC *usecase.PurgeDataUseCase | |||
| 19 | + | ||||
| 20 | + | loading bool | |||
| 21 | + | purging bool | |||
| 22 | + | done bool | |||
| 23 | + | ||||
| 24 | + | results *usecase.PurgeOutput | |||
| 25 | + | info string | |||
| 26 | + | err error | |||
| 27 | + | ||||
| 28 | + | theme *styles.Theme | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // NewPurgeModel creates a new purge command model. | |||
| 32 | + | func NewPurgeModel(theme *styles.Theme, purgeUC *usecase.PurgeDataUseCase) PurgeModel { | |||
| 33 | + | return PurgeModel{theme: theme, purgeUC: purgeUC, loading: true} | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | type purgeTargetsLoadedMsg struct { | |||
| 37 | + | targets []entity.PurgeTarget | |||
| 38 | + | err error | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | type purgeCompleteMsg struct { | |||
| 42 | + | out *usecase.PurgeOutput | |||
| 43 | + | err error | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // Init implements tea.Model. | |||
| 47 | + | func (m PurgeModel) Init() tea.Cmd { | |||
| 48 | + | return m.loadTargets() | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func (m PurgeModel) loadTargets() tea.Cmd { | |||
| 52 | + | return func() tea.Msg { | |||
| 53 | + | targets, err := m.purgeUC.GetPurgeTargets(context.Background()) | |||
| 54 | + | return purgeTargetsLoadedMsg{targets: targets, err: err} | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // Update implements tea.Model. | |||
| 59 | + | func (m PurgeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |||
| 60 | + | switch msg := msg.(type) { | |||
| 61 | + | case purgeTargetsLoadedMsg: | |||
| 62 | + | m.loading = false | |||
| 63 | + | if msg.err != nil { | |||
| 64 | + | m.err = msg.err | |||
| 65 | + | m.done = true | |||
| 66 | + | return m, nil | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | m.selector = styles.NewPurge(m.theme, msg.targets) | |||
| 70 | + | return m, nil | |||
| 71 | + | ||||
| 72 | + | case purgeCompleteMsg: | |||
| 73 | + | m.purging = false | |||
| 74 | + | m.done = true | |||
| 75 | + | m.results = msg.out | |||
| 76 | + | if msg.err != nil { | |||
| 77 | + | m.err = msg.err | |||
| 78 | + | } | |||
| 79 | + | return m, nil | |||
| 80 | + | ||||
| 81 | + | case tea.KeyMsg: | |||
| 82 | + | if m.done { | |||
| 83 | + | return m, tea.Quit | |||
| 84 | + | } | |||
| 85 | + | if m.loading || m.purging { | |||
| 86 | + | return m, nil | |||
| 87 | + | } | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | if m.loading || m.purging { | |||
| 91 | + | return m, nil | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | selector, cmd := m.selector.Update(msg) | |||
| 95 | + | m.selector = selector | |||
| 96 | + | ||||
| 97 | + | if m.selector.Done() { | |||
| 98 | + | if m.selector.Canceled { | |||
| 99 | + | return m, tea.Quit | |||
| 100 | + | } | |||
| 101 | + | if m.selector.Confirmed { | |||
| 102 | + | targetTypes := m.selector.SelectedTypes() | |||
| 103 | + | if len(targetTypes) == 0 { | |||
| 104 | + | m.done = true | |||
| 105 | + | m.info = "Nothing selected" | |||
| 106 | + | return m, nil | |||
| 107 | + | } | |||
| 108 | + | m.purging = true | |||
| 109 | + | return m, m.performPurge(targetTypes) | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | return m, cmd | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | func (m PurgeModel) performPurge(targetTypes []entity.PurgeTargetType) tea.Cmd { | |||
| 117 | + | return func() tea.Msg { | |||
| 118 | + | out, err := m.purgeUC.Execute(context.Background(), usecase.PurgeInput{TargetTypes: targetTypes}) | |||
| 119 | + | return purgeCompleteMsg{out: out, err: err} | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | // View implements tea.Model. | |||
| 124 | + | func (m PurgeModel) View() string { | |||
| 125 | + | t := m.theme | |||
| 126 | + | ||||
| 127 | + | if m.loading { | |||
| 128 | + | spinner := styles.NewLoading(t, "Scanning purge targets...") | |||
| 129 | + | return t.Box.Render(spinner.View()) | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | if m.purging { | |||
| 133 | + | spinner := styles.NewLoading(t, "Purging...") | |||
| 134 | + | return t.Box.Render(spinner.View()) | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | if m.done { | |||
| 138 | + | if m.info != "" { | |||
| 139 | + | content := lipgloss.JoinVertical( | |||
| 140 | + | lipgloss.Left, | |||
| 141 | + | t.Subtle.Render(m.info), | |||
| 142 | + | "", | |||
| 143 | + | t.Subtle.Render("Press any key to exit"), | |||
| 144 | + | ) | |||
| 145 | + | return t.Box.Render(content) | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | if m.err != nil && m.results == nil { | |||
| 149 | + | content := lipgloss.JoinVertical( | |||
| 150 | + | lipgloss.Left, | |||
| 151 | + | t.ErrorStyle.Render("Error: "+m.err.Error()), | |||
| 152 | + | "", | |||
| 153 | + | t.Subtle.Render("Press any key to exit"), | |||
| 154 | + | ) | |||
| 155 | + | return t.Box.Render(content) | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | contentLines := []string{t.Title.Render("Purge complete")} | |||
| 159 | + | if m.results != nil { | |||
| 160 | + | for _, r := range m.results.Results { | |||
| 161 | + | if r.Success { | |||
| 162 | + | contentLines = append(contentLines, fmt.Sprintf("%s %s", t.SuccessStyle.Render(styles.IconCheck), r.Target.Path)) | |||
| 163 | + | } else { | |||
| 164 | + | contentLines = append(contentLines, fmt.Sprintf("%s %s: %v", t.ErrorStyle.Render(styles.IconX), r.Target.Path, r.Error)) | |||
| 165 | + | } | |||
| 166 | + | } | |||
| 167 | + | contentLines = append( | |||
| 168 | + | contentLines, | |||
| 169 | + | "", | |||
| 170 | + | t.Subtle.Render( | |||
| 171 | + | fmt.Sprintf("%d succeeded, %d failed", m.results.SuccessCount, m.results.FailureCount), | |||
| 172 | + | ), | |||
| 173 | + | ) | |||
| 174 | + | } | |||
| 175 | + | contentLines = append(contentLines, "", t.Subtle.Render("Press any key to exit")) | |||
| 176 | + | return t.Box.Render(lipgloss.JoinVertical(lipgloss.Left, contentLines...)) | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | return m.selector.View() | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | var _ tea.Model = (*PurgeModel)(nil) | |||
M internal/cli/styles/icons.go +18 -0
| @@ -22,4 +22,22 @@ const ( | |||
| 22 | 22 | IconWrench = "\uf0ad" // wrench | |
| 23 | 23 | IconPackage = "\uf187" // archive/package | |
| 24 | 24 | IconVideo = "\uf03d" // video camera | |
| 25 | + | ||
| 26 | + | // Purge / filesystem | |
| 27 | + | IconTrash = "\uf1f8" // trash | |
| 28 | + | IconFolder = "\uf07b" // folder | |
| 29 | + | IconConfig = "\ue615" // config | |
| 30 | + | IconDatabase = "\uf1c0" // database | |
| 31 | + | IconFilter = "\uf0b0" // filter | |
| 32 | + | IconDesktop = "\uf108" // desktop | |
| 33 | + | IconImage = "\uf1c5" // image file | |
| 34 | + | IconCache = "\uf49e" // cache | |
| 35 | + | IconLogs = "\uf0f6" // file-text | |
| 36 | + | ||
| 37 | + | // Checkboxes | |
| 38 | + | IconCheckboxEmpty = "\uf096" // unchecked | |
| 39 | + | IconCheckboxChecked = "\uf046" // checked | |
| 40 | + | ||
| 41 | + | // UI | |
| 42 | + | IconCursor = "\uf054" // chevron-right | |
| 25 | 43 | ) | |
| @@ -22,4 +22,22 @@ const ( | |||||
| 22 | IconWrench = "\uf0ad" // wrench | 22 | IconWrench = "\uf0ad" // wrench | ||
| 23 | IconPackage = "\uf187" // archive/package | 23 | IconPackage = "\uf187" // archive/package | ||
| 24 | IconVideo = "\uf03d" // video camera | 24 | IconVideo = "\uf03d" // video camera | ||
| 25 | + | ||||
| 26 | + | // Purge / filesystem | |||
| 27 | + | IconTrash = "\uf1f8" // trash | |||
| 28 | + | IconFolder = "\uf07b" // folder | |||
| 29 | + | IconConfig = "\ue615" // config | |||
| 30 | + | IconDatabase = "\uf1c0" // database | |||
| 31 | + | IconFilter = "\uf0b0" // filter | |||
| 32 | + | IconDesktop = "\uf108" // desktop | |||
| 33 | + | IconImage = "\uf1c5" // image file | |||
| 34 | + | IconCache = "\uf49e" // cache | |||
| 35 | + | IconLogs = "\uf0f6" // file-text | |||
| 36 | + | ||||
| 37 | + | // Checkboxes | |||
| 38 | + | IconCheckboxEmpty = "\uf096" // unchecked | |||
| 39 | + | IconCheckboxChecked = "\uf046" // checked | |||
| 40 | + | ||||
| 41 | + | // UI | |||
| 42 | + | IconCursor = "\uf054" // chevron-right | |||
| 25 | ) | 43 | ) | ||
A internal/cli/styles/purge.go +332 -0
| @@ -0,0 +1,332 @@ | |||
| 1 | + | package styles | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "strings" | |
| 6 | + | ||
| 7 | + | "github.com/charmbracelet/bubbles/key" | |
| 8 | + | tea "github.com/charmbracelet/bubbletea" | |
| 9 | + | "github.com/charmbracelet/lipgloss" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // PurgeItem wraps entity.PurgeTarget with selection state for the UI. | |
| 15 | + | type PurgeItem struct { | |
| 16 | + | entity.PurgeTarget | |
| 17 | + | Selected bool | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // PurgeModel is the multi-select purge modal. | |
| 21 | + | type PurgeModel struct { | |
| 22 | + | Items []PurgeItem | |
| 23 | + | Cursor int | |
| 24 | + | Confirmed bool | |
| 25 | + | Canceled bool | |
| 26 | + | theme *Theme | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // PurgeKeyMap defines keybindings for purge modal. | |
| 30 | + | type PurgeKeyMap struct { | |
| 31 | + | Up key.Binding | |
| 32 | + | Down key.Binding | |
| 33 | + | Toggle key.Binding | |
| 34 | + | ToggleAll key.Binding | |
| 35 | + | Confirm key.Binding | |
| 36 | + | Cancel key.Binding | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // DefaultPurgeKeyMap returns default keybindings. | |
| 40 | + | func DefaultPurgeKeyMap() PurgeKeyMap { | |
| 41 | + | return PurgeKeyMap{ | |
| 42 | + | Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")), | |
| 43 | + | Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), | |
| 44 | + | Toggle: key.NewBinding(key.WithKeys(" "), key.WithHelp("space", "toggle")), | |
| 45 | + | ToggleAll: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "toggle all")), | |
| 46 | + | Confirm: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")), | |
| 47 | + | Cancel: key.NewBinding(key.WithKeys("esc", "q"), key.WithHelp("esc", "cancel")), | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // NewPurge creates a new purge modal with the given targets. | |
| 52 | + | func NewPurge(theme *Theme, targets []entity.PurgeTarget) PurgeModel { | |
| 53 | + | items := make([]PurgeItem, 0, len(targets)) | |
| 54 | + | for _, t := range targets { | |
| 55 | + | items = append(items, PurgeItem{PurgeTarget: t, Selected: t.Exists}) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | m := PurgeModel{Items: items, Cursor: 0, theme: theme} | |
| 59 | + | m.Cursor = m.firstSelectableIndex() | |
| 60 | + | return m | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // Init implements tea.Model. | |
| 64 | + | func (m PurgeModel) Init() tea.Cmd { | |
| 65 | + | _ = m | |
| 66 | + | return nil | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // Update implements tea.Model. | |
| 70 | + | func (m PurgeModel) Update(msg tea.Msg) (PurgeModel, tea.Cmd) { | |
| 71 | + | keys := DefaultPurgeKeyMap() | |
| 72 | + | ||
| 73 | + | switch msg := msg.(type) { | |
| 74 | + | case tea.KeyMsg: | |
| 75 | + | switch { | |
| 76 | + | case key.Matches(msg, keys.Up): | |
| 77 | + | m.moveCursor(-1) | |
| 78 | + | case key.Matches(msg, keys.Down): | |
| 79 | + | m.moveCursor(1) | |
| 80 | + | case key.Matches(msg, keys.Toggle): | |
| 81 | + | m.toggleCurrent() | |
| 82 | + | case key.Matches(msg, keys.ToggleAll): | |
| 83 | + | m.toggleAll() | |
| 84 | + | case key.Matches(msg, keys.Confirm): | |
| 85 | + | m.Confirmed = true | |
| 86 | + | case key.Matches(msg, keys.Cancel): | |
| 87 | + | m.Canceled = true | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | return m, nil | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func (m *PurgeModel) moveCursor(delta int) { | |
| 95 | + | if len(m.Items) == 0 { | |
| 96 | + | return | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | idx := m.Cursor | |
| 100 | + | for i := 0; i < len(m.Items); i++ { | |
| 101 | + | idx = (idx + delta + len(m.Items)) % len(m.Items) | |
| 102 | + | if m.Items[idx].Exists { | |
| 103 | + | m.Cursor = idx | |
| 104 | + | return | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func (m PurgeModel) firstSelectableIndex() int { | |
| 110 | + | for i, it := range m.Items { | |
| 111 | + | if it.Exists { | |
| 112 | + | return i | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | return 0 | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func (m *PurgeModel) toggleCurrent() { | |
| 119 | + | if m.Cursor < 0 || m.Cursor >= len(m.Items) { | |
| 120 | + | return | |
| 121 | + | } | |
| 122 | + | if !m.Items[m.Cursor].Exists { | |
| 123 | + | return | |
| 124 | + | } | |
| 125 | + | m.Items[m.Cursor].Selected = !m.Items[m.Cursor].Selected | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func (m *PurgeModel) toggleAll() { | |
| 129 | + | anyUnselected := false | |
| 130 | + | for _, it := range m.Items { | |
| 131 | + | if it.Exists && !it.Selected { | |
| 132 | + | anyUnselected = true | |
| 133 | + | break | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | for i := range m.Items { | |
| 138 | + | if !m.Items[i].Exists { | |
| 139 | + | continue | |
| 140 | + | } | |
| 141 | + | m.Items[i].Selected = anyUnselected | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // View implements tea.Model. | |
| 146 | + | func (m PurgeModel) View() string { | |
| 147 | + | t := m.theme | |
| 148 | + | ||
| 149 | + | header := t.Title.Render(fmt.Sprintf("%s Purge", IconTrash)) | |
| 150 | + | subtitle := t.Subtle.Render("Select items to remove") | |
| 151 | + | ||
| 152 | + | var rows []string | |
| 153 | + | for i, it := range m.Items { | |
| 154 | + | rows = append(rows, m.renderRow(i, it)) | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | list := lipgloss.JoinVertical(lipgloss.Left, rows...) | |
| 158 | + | ||
| 159 | + | selectedCount := m.SelectedCount() | |
| 160 | + | size := m.SelectedSize() | |
| 161 | + | var summary string | |
| 162 | + | if selectedCount > 0 { | |
| 163 | + | summary = lipgloss.JoinHorizontal( | |
| 164 | + | lipgloss.Left, | |
| 165 | + | t.WarningStyle.Render(IconWarning), | |
| 166 | + | " ", | |
| 167 | + | t.Subtle.Render(fmt.Sprintf("%d selected", selectedCount)), | |
| 168 | + | " ", | |
| 169 | + | t.Subtle.Render(fmt.Sprintf("(%s)", formatSize(size))), | |
| 170 | + | ) | |
| 171 | + | } else { | |
| 172 | + | summary = t.Subtle.Render("0 selected") | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | help := t.Subtle.Render("↑/↓ or j/k to move • space to toggle • a toggle all • enter confirm • esc cancel") | |
| 176 | + | ||
| 177 | + | content := lipgloss.JoinVertical( | |
| 178 | + | lipgloss.Left, | |
| 179 | + | header, | |
| 180 | + | subtitle, | |
| 181 | + | "", | |
| 182 | + | list, | |
| 183 | + | "", | |
| 184 | + | summary, | |
| 185 | + | "", | |
| 186 | + | help, | |
| 187 | + | ) | |
| 188 | + | ||
| 189 | + | return t.Box.Render(content) | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | func (m PurgeModel) renderRow(i int, it PurgeItem) string { | |
| 193 | + | t := m.theme | |
| 194 | + | ||
| 195 | + | cursor := " " | |
| 196 | + | if i == m.Cursor { | |
| 197 | + | cursor = IconCursor + " " | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | checkbox := IconCheckboxEmpty | |
| 201 | + | if it.Selected { | |
| 202 | + | checkbox = IconCheckboxChecked | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | label := purgeLabel(it.Type) | |
| 206 | + | ||
| 207 | + | pathStyle := t.Subtle | |
| 208 | + | labelStyle := t.Normal | |
| 209 | + | sizeStyle := t.Subtle | |
| 210 | + | checkboxStyle := lipgloss.NewStyle().Foreground(t.Accent) | |
| 211 | + | cursorStyle := lipgloss.NewStyle().Foreground(t.Accent) | |
| 212 | + | ||
| 213 | + | missing := "" | |
| 214 | + | if !it.Exists { | |
| 215 | + | checkbox = IconCheckboxEmpty | |
| 216 | + | checkboxStyle = t.Subtle | |
| 217 | + | cursorStyle = t.Subtle | |
| 218 | + | labelStyle = t.Subtle | |
| 219 | + | pathStyle = t.Subtle | |
| 220 | + | sizeStyle = t.Subtle | |
| 221 | + | missing = t.Subtle.Render("(not found)") | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | size := "" | |
| 225 | + | if it.Exists { | |
| 226 | + | size = sizeStyle.Render(formatSize(it.Size)) | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | const labelPadWidth = 12 | |
| 230 | + | left := lipgloss.JoinHorizontal( | |
| 231 | + | lipgloss.Left, | |
| 232 | + | cursorStyle.Render(cursor), | |
| 233 | + | checkboxStyle.Render(checkbox), | |
| 234 | + | " ", | |
| 235 | + | labelStyle.Render(padRight(label, labelPadWidth)), | |
| 236 | + | pathStyle.Render(it.Path), | |
| 237 | + | ) | |
| 238 | + | ||
| 239 | + | if it.Exists { | |
| 240 | + | return lipgloss.JoinHorizontal(lipgloss.Left, left, " ", size) | |
| 241 | + | } | |
| 242 | + | return lipgloss.JoinHorizontal(lipgloss.Left, left, " ", missing) | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | func purgeLabel(t entity.PurgeTargetType) string { | |
| 246 | + | switch t { | |
| 247 | + | case entity.PurgeTargetConfig: | |
| 248 | + | return fmt.Sprintf("%s Config", IconConfig) | |
| 249 | + | case entity.PurgeTargetData: | |
| 250 | + | return fmt.Sprintf("%s Data", IconDatabase) | |
| 251 | + | case entity.PurgeTargetState: | |
| 252 | + | return fmt.Sprintf("%s State", IconLogs) | |
| 253 | + | case entity.PurgeTargetCache: | |
| 254 | + | return fmt.Sprintf("%s Cache", IconCache) | |
| 255 | + | case entity.PurgeTargetFilterJSON: | |
| 256 | + | return fmt.Sprintf("%s Filter JSON", IconFilter) | |
| 257 | + | case entity.PurgeTargetFilterStore: | |
| 258 | + | return fmt.Sprintf("%s Filter Store", IconFilter) | |
| 259 | + | case entity.PurgeTargetFilterCache: | |
| 260 | + | return fmt.Sprintf("%s Filter Cache", IconFilter) | |
| 261 | + | case entity.PurgeTargetDesktopFile: | |
| 262 | + | return fmt.Sprintf("%s Desktop file", IconDesktop) | |
| 263 | + | case entity.PurgeTargetIcon: | |
| 264 | + | return fmt.Sprintf("%s Icon", IconImage) | |
| 265 | + | default: | |
| 266 | + | return "Item" | |
| 267 | + | } | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | func padRight(s string, width int) string { | |
| 271 | + | r := []rune(s) | |
| 272 | + | if len(r) >= width { | |
| 273 | + | return s | |
| 274 | + | } | |
| 275 | + | return s + strings.Repeat(" ", width-len(r)) | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | func formatSize(bytes int64) string { | |
| 279 | + | const unit = 1024 | |
| 280 | + | if bytes < unit { | |
| 281 | + | return fmt.Sprintf("%d B", bytes) | |
| 282 | + | } | |
| 283 | + | div, exp := int64(unit), 0 | |
| 284 | + | for n := bytes / unit; n >= unit; n /= unit { | |
| 285 | + | div *= unit | |
| 286 | + | exp++ | |
| 287 | + | } | |
| 288 | + | return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | // Done returns true if the modal is complete. | |
| 292 | + | func (m PurgeModel) Done() bool { | |
| 293 | + | return m.Confirmed || m.Canceled | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | // SelectedTypes returns the selected target types. | |
| 297 | + | func (m PurgeModel) SelectedTypes() []entity.PurgeTargetType { | |
| 298 | + | selected := make(map[entity.PurgeTargetType]struct{}) | |
| 299 | + | for _, it := range m.Items { | |
| 300 | + | if it.Exists && it.Selected { | |
| 301 | + | selected[it.Type] = struct{}{} | |
| 302 | + | } | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | out := make([]entity.PurgeTargetType, 0, len(selected)) | |
| 306 | + | for tt := range selected { | |
| 307 | + | out = append(out, tt) | |
| 308 | + | } | |
| 309 | + | return out | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | // SelectedCount returns the number of selected, existing items. | |
| 313 | + | func (m PurgeModel) SelectedCount() int { | |
| 314 | + | count := 0 | |
| 315 | + | for _, it := range m.Items { | |
| 316 | + | if it.Exists && it.Selected { | |
| 317 | + | count++ | |
| 318 | + | } | |
| 319 | + | } | |
| 320 | + | return count | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | // SelectedSize returns the total size of selected, existing items. | |
| 324 | + | func (m PurgeModel) SelectedSize() int64 { | |
| 325 | + | var total int64 | |
| 326 | + | for _, it := range m.Items { | |
| 327 | + | if it.Exists && it.Selected { | |
| 328 | + | total += it.Size | |
| 329 | + | } | |
| 330 | + | } | |
| 331 | + | return total | |
| 332 | + | } | |
| @@ -0,0 +1,332 @@ | |||||
| 1 | + | package styles | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "strings" | |||
| 6 | + | ||||
| 7 | + | "github.com/charmbracelet/bubbles/key" | |||
| 8 | + | tea "github.com/charmbracelet/bubbletea" | |||
| 9 | + | "github.com/charmbracelet/lipgloss" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // PurgeItem wraps entity.PurgeTarget with selection state for the UI. | |||
| 15 | + | type PurgeItem struct { | |||
| 16 | + | entity.PurgeTarget | |||
| 17 | + | Selected bool | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // PurgeModel is the multi-select purge modal. | |||
| 21 | + | type PurgeModel struct { | |||
| 22 | + | Items []PurgeItem | |||
| 23 | + | Cursor int | |||
| 24 | + | Confirmed bool | |||
| 25 | + | Canceled bool | |||
| 26 | + | theme *Theme | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // PurgeKeyMap defines keybindings for purge modal. | |||
| 30 | + | type PurgeKeyMap struct { | |||
| 31 | + | Up key.Binding | |||
| 32 | + | Down key.Binding | |||
| 33 | + | Toggle key.Binding | |||
| 34 | + | ToggleAll key.Binding | |||
| 35 | + | Confirm key.Binding | |||
| 36 | + | Cancel key.Binding | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // DefaultPurgeKeyMap returns default keybindings. | |||
| 40 | + | func DefaultPurgeKeyMap() PurgeKeyMap { | |||
| 41 | + | return PurgeKeyMap{ | |||
| 42 | + | Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")), | |||
| 43 | + | Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), | |||
| 44 | + | Toggle: key.NewBinding(key.WithKeys(" "), key.WithHelp("space", "toggle")), | |||
| 45 | + | ToggleAll: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "toggle all")), | |||
| 46 | + | Confirm: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")), | |||
| 47 | + | Cancel: key.NewBinding(key.WithKeys("esc", "q"), key.WithHelp("esc", "cancel")), | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // NewPurge creates a new purge modal with the given targets. | |||
| 52 | + | func NewPurge(theme *Theme, targets []entity.PurgeTarget) PurgeModel { | |||
| 53 | + | items := make([]PurgeItem, 0, len(targets)) | |||
| 54 | + | for _, t := range targets { | |||
| 55 | + | items = append(items, PurgeItem{PurgeTarget: t, Selected: t.Exists}) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | m := PurgeModel{Items: items, Cursor: 0, theme: theme} | |||
| 59 | + | m.Cursor = m.firstSelectableIndex() | |||
| 60 | + | return m | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // Init implements tea.Model. | |||
| 64 | + | func (m PurgeModel) Init() tea.Cmd { | |||
| 65 | + | _ = m | |||
| 66 | + | return nil | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | // Update implements tea.Model. | |||
| 70 | + | func (m PurgeModel) Update(msg tea.Msg) (PurgeModel, tea.Cmd) { | |||
| 71 | + | keys := DefaultPurgeKeyMap() | |||
| 72 | + | ||||
| 73 | + | switch msg := msg.(type) { | |||
| 74 | + | case tea.KeyMsg: | |||
| 75 | + | switch { | |||
| 76 | + | case key.Matches(msg, keys.Up): | |||
| 77 | + | m.moveCursor(-1) | |||
| 78 | + | case key.Matches(msg, keys.Down): | |||
| 79 | + | m.moveCursor(1) | |||
| 80 | + | case key.Matches(msg, keys.Toggle): | |||
| 81 | + | m.toggleCurrent() | |||
| 82 | + | case key.Matches(msg, keys.ToggleAll): | |||
| 83 | + | m.toggleAll() | |||
| 84 | + | case key.Matches(msg, keys.Confirm): | |||
| 85 | + | m.Confirmed = true | |||
| 86 | + | case key.Matches(msg, keys.Cancel): | |||
| 87 | + | m.Canceled = true | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | return m, nil | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func (m *PurgeModel) moveCursor(delta int) { | |||
| 95 | + | if len(m.Items) == 0 { | |||
| 96 | + | return | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | idx := m.Cursor | |||
| 100 | + | for i := 0; i < len(m.Items); i++ { | |||
| 101 | + | idx = (idx + delta + len(m.Items)) % len(m.Items) | |||
| 102 | + | if m.Items[idx].Exists { | |||
| 103 | + | m.Cursor = idx | |||
| 104 | + | return | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func (m PurgeModel) firstSelectableIndex() int { | |||
| 110 | + | for i, it := range m.Items { | |||
| 111 | + | if it.Exists { | |||
| 112 | + | return i | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | return 0 | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func (m *PurgeModel) toggleCurrent() { | |||
| 119 | + | if m.Cursor < 0 || m.Cursor >= len(m.Items) { | |||
| 120 | + | return | |||
| 121 | + | } | |||
| 122 | + | if !m.Items[m.Cursor].Exists { | |||
| 123 | + | return | |||
| 124 | + | } | |||
| 125 | + | m.Items[m.Cursor].Selected = !m.Items[m.Cursor].Selected | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func (m *PurgeModel) toggleAll() { | |||
| 129 | + | anyUnselected := false | |||
| 130 | + | for _, it := range m.Items { | |||
| 131 | + | if it.Exists && !it.Selected { | |||
| 132 | + | anyUnselected = true | |||
| 133 | + | break | |||
| 134 | + | } | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | for i := range m.Items { | |||
| 138 | + | if !m.Items[i].Exists { | |||
| 139 | + | continue | |||
| 140 | + | } | |||
| 141 | + | m.Items[i].Selected = anyUnselected | |||
| 142 | + | } | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | // View implements tea.Model. | |||
| 146 | + | func (m PurgeModel) View() string { | |||
| 147 | + | t := m.theme | |||
| 148 | + | ||||
| 149 | + | header := t.Title.Render(fmt.Sprintf("%s Purge", IconTrash)) | |||
| 150 | + | subtitle := t.Subtle.Render("Select items to remove") | |||
| 151 | + | ||||
| 152 | + | var rows []string | |||
| 153 | + | for i, it := range m.Items { | |||
| 154 | + | rows = append(rows, m.renderRow(i, it)) | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | list := lipgloss.JoinVertical(lipgloss.Left, rows...) | |||
| 158 | + | ||||
| 159 | + | selectedCount := m.SelectedCount() | |||
| 160 | + | size := m.SelectedSize() | |||
| 161 | + | var summary string | |||
| 162 | + | if selectedCount > 0 { | |||
| 163 | + | summary = lipgloss.JoinHorizontal( | |||
| 164 | + | lipgloss.Left, | |||
| 165 | + | t.WarningStyle.Render(IconWarning), | |||
| 166 | + | " ", | |||
| 167 | + | t.Subtle.Render(fmt.Sprintf("%d selected", selectedCount)), | |||
| 168 | + | " ", | |||
| 169 | + | t.Subtle.Render(fmt.Sprintf("(%s)", formatSize(size))), | |||
| 170 | + | ) | |||
| 171 | + | } else { | |||
| 172 | + | summary = t.Subtle.Render("0 selected") | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | help := t.Subtle.Render("↑/↓ or j/k to move • space to toggle • a toggle all • enter confirm • esc cancel") | |||
| 176 | + | ||||
| 177 | + | content := lipgloss.JoinVertical( | |||
| 178 | + | lipgloss.Left, | |||
| 179 | + | header, | |||
| 180 | + | subtitle, | |||
| 181 | + | "", | |||
| 182 | + | list, | |||
| 183 | + | "", | |||
| 184 | + | summary, | |||
| 185 | + | "", | |||
| 186 | + | help, | |||
| 187 | + | ) | |||
| 188 | + | ||||
| 189 | + | return t.Box.Render(content) | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | func (m PurgeModel) renderRow(i int, it PurgeItem) string { | |||
| 193 | + | t := m.theme | |||
| 194 | + | ||||
| 195 | + | cursor := " " | |||
| 196 | + | if i == m.Cursor { | |||
| 197 | + | cursor = IconCursor + " " | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | checkbox := IconCheckboxEmpty | |||
| 201 | + | if it.Selected { | |||
| 202 | + | checkbox = IconCheckboxChecked | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | label := purgeLabel(it.Type) | |||
| 206 | + | ||||
| 207 | + | pathStyle := t.Subtle | |||
| 208 | + | labelStyle := t.Normal | |||
| 209 | + | sizeStyle := t.Subtle | |||
| 210 | + | checkboxStyle := lipgloss.NewStyle().Foreground(t.Accent) | |||
| 211 | + | cursorStyle := lipgloss.NewStyle().Foreground(t.Accent) | |||
| 212 | + | ||||
| 213 | + | missing := "" | |||
| 214 | + | if !it.Exists { | |||
| 215 | + | checkbox = IconCheckboxEmpty | |||
| 216 | + | checkboxStyle = t.Subtle | |||
| 217 | + | cursorStyle = t.Subtle | |||
| 218 | + | labelStyle = t.Subtle | |||
| 219 | + | pathStyle = t.Subtle | |||
| 220 | + | sizeStyle = t.Subtle | |||
| 221 | + | missing = t.Subtle.Render("(not found)") | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | size := "" | |||
| 225 | + | if it.Exists { | |||
| 226 | + | size = sizeStyle.Render(formatSize(it.Size)) | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | const labelPadWidth = 12 | |||
| 230 | + | left := lipgloss.JoinHorizontal( | |||
| 231 | + | lipgloss.Left, | |||
| 232 | + | cursorStyle.Render(cursor), | |||
| 233 | + | checkboxStyle.Render(checkbox), | |||
| 234 | + | " ", | |||
| 235 | + | labelStyle.Render(padRight(label, labelPadWidth)), | |||
| 236 | + | pathStyle.Render(it.Path), | |||
| 237 | + | ) | |||
| 238 | + | ||||
| 239 | + | if it.Exists { | |||
| 240 | + | return lipgloss.JoinHorizontal(lipgloss.Left, left, " ", size) | |||
| 241 | + | } | |||
| 242 | + | return lipgloss.JoinHorizontal(lipgloss.Left, left, " ", missing) | |||
| 243 | + | } | |||
| 244 | + | ||||
| 245 | + | func purgeLabel(t entity.PurgeTargetType) string { | |||
| 246 | + | switch t { | |||
| 247 | + | case entity.PurgeTargetConfig: | |||
| 248 | + | return fmt.Sprintf("%s Config", IconConfig) | |||
| 249 | + | case entity.PurgeTargetData: | |||
| 250 | + | return fmt.Sprintf("%s Data", IconDatabase) | |||
| 251 | + | case entity.PurgeTargetState: | |||
| 252 | + | return fmt.Sprintf("%s State", IconLogs) | |||
| 253 | + | case entity.PurgeTargetCache: | |||
| 254 | + | return fmt.Sprintf("%s Cache", IconCache) | |||
| 255 | + | case entity.PurgeTargetFilterJSON: | |||
| 256 | + | return fmt.Sprintf("%s Filter JSON", IconFilter) | |||
| 257 | + | case entity.PurgeTargetFilterStore: | |||
| 258 | + | return fmt.Sprintf("%s Filter Store", IconFilter) | |||
| 259 | + | case entity.PurgeTargetFilterCache: | |||
| 260 | + | return fmt.Sprintf("%s Filter Cache", IconFilter) | |||
| 261 | + | case entity.PurgeTargetDesktopFile: | |||
| 262 | + | return fmt.Sprintf("%s Desktop file", IconDesktop) | |||
| 263 | + | case entity.PurgeTargetIcon: | |||
| 264 | + | return fmt.Sprintf("%s Icon", IconImage) | |||
| 265 | + | default: | |||
| 266 | + | return "Item" | |||
| 267 | + | } | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | func padRight(s string, width int) string { | |||
| 271 | + | r := []rune(s) | |||
| 272 | + | if len(r) >= width { | |||
| 273 | + | return s | |||
| 274 | + | } | |||
| 275 | + | return s + strings.Repeat(" ", width-len(r)) | |||
| 276 | + | } | |||
| 277 | + | ||||
| 278 | + | func formatSize(bytes int64) string { | |||
| 279 | + | const unit = 1024 | |||
| 280 | + | if bytes < unit { | |||
| 281 | + | return fmt.Sprintf("%d B", bytes) | |||
| 282 | + | } | |||
| 283 | + | div, exp := int64(unit), 0 | |||
| 284 | + | for n := bytes / unit; n >= unit; n /= unit { | |||
| 285 | + | div *= unit | |||
| 286 | + | exp++ | |||
| 287 | + | } | |||
| 288 | + | return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) | |||
| 289 | + | } | |||
| 290 | + | ||||
| 291 | + | // Done returns true if the modal is complete. | |||
| 292 | + | func (m PurgeModel) Done() bool { | |||
| 293 | + | return m.Confirmed || m.Canceled | |||
| 294 | + | } | |||
| 295 | + | ||||
| 296 | + | // SelectedTypes returns the selected target types. | |||
| 297 | + | func (m PurgeModel) SelectedTypes() []entity.PurgeTargetType { | |||
| 298 | + | selected := make(map[entity.PurgeTargetType]struct{}) | |||
| 299 | + | for _, it := range m.Items { | |||
| 300 | + | if it.Exists && it.Selected { | |||
| 301 | + | selected[it.Type] = struct{}{} | |||
| 302 | + | } | |||
| 303 | + | } | |||
| 304 | + | ||||
| 305 | + | out := make([]entity.PurgeTargetType, 0, len(selected)) | |||
| 306 | + | for tt := range selected { | |||
| 307 | + | out = append(out, tt) | |||
| 308 | + | } | |||
| 309 | + | return out | |||
| 310 | + | } | |||
| 311 | + | ||||
| 312 | + | // SelectedCount returns the number of selected, existing items. | |||
| 313 | + | func (m PurgeModel) SelectedCount() int { | |||
| 314 | + | count := 0 | |||
| 315 | + | for _, it := range m.Items { | |||
| 316 | + | if it.Exists && it.Selected { | |||
| 317 | + | count++ | |||
| 318 | + | } | |||
| 319 | + | } | |||
| 320 | + | return count | |||
| 321 | + | } | |||
| 322 | + | ||||
| 323 | + | // SelectedSize returns the total size of selected, existing items. | |||
| 324 | + | func (m PurgeModel) SelectedSize() int64 { | |||
| 325 | + | var total int64 | |||
| 326 | + | for _, it := range m.Items { | |||
| 327 | + | if it.Exists && it.Selected { | |||
| 328 | + | total += it.Size | |||
| 329 | + | } | |||
| 330 | + | } | |||
| 331 | + | return total | |||
| 332 | + | } | |||