detached b0d67b5

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 @@
11 package cmd
22
33 import (
4- "bufio"
5- "context"
64 "fmt"
7- "os"
8- "path/filepath"
9- "strings"
105
6+ tea "github.com/charmbracelet/bubbletea"
117 "github.com/spf13/cobra"
128
139 "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"
1413 "github.com/bnema/dumber/internal/infrastructure/desktop"
14+ "github.com/bnema/dumber/internal/infrastructure/filesystem"
15+ xdgadapter "github.com/bnema/dumber/internal/infrastructure/xdg"
1516 )
1617
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
4419
4520 var purgeCmd = &cobra.Command{
4621 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.`,
5934 RunE: runPurge,
6035 }
6136
6237 func init() {
6338 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")
6840 }
6941
7042 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")
11946 }
12047
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)
18352
184-func confirmPurge() bool {
18553 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)
21655 }
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-}
24956
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
26261 }
26362
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+ }
29072 }
29173 }
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
34175 }
@@ -1,341 +1,75 @@
1 package cmd1 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 nil50+ 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 true54+ 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 = 102459+ _, 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 string67+ if r.Success {
269- desc string68+ 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 (
2222 IconWrench = "\uf0ad" // wrench
2323 IconPackage = "\uf187" // archive/package
2424 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
2543 )
@@ -22,4 +22,22 @@ const (
22 IconWrench = "\uf0ad" // wrench22 IconWrench = "\uf0ad" // wrench
23 IconPackage = "\uf187" // archive/package23 IconPackage = "\uf187" // archive/package
24 IconVideo = "\uf03d" // video camera24 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close