detached d4c1f57

feat(cli): implement purge command for data cleanup

Add comprehensive purge command with selective cleaning options:
- Database purging (history, shortcuts, zoom levels)
- Cache purging (WebKit cache, dmenu fuzzy cache)
- WebKit data purging (cookies, local storage)
- State and config purging
- Confirmation prompt with --force override
- Detailed help and usage examples
commit
d4c1f57c803daac45852782d66e09ef375338459
author
Brice · · 2025-09-10 20:12 UTC
parent
884905a
signature
unsigned
4 files · +273 -0
M CHANGELOG.md +4 -0
@@ -16,6 +16,10 @@ All notable changes to this project will be documented in this file.
1616 - XDG Base Directory Specification compliance (cache stored in state directory)
1717 - Atomic operations for concurrent access control
1818 - Dependency injection using interfaces and gomock for testing
19+- Purge command for cleaning up dumber data and cache files:
20+ - Selective purging with flags: `--database`, `--cache`, `--webkit-data`, `--state`, `--config`
21+ - Confirmation prompt with `--force` flag to skip
22+ - Targets all data locations: SQLite database, WebKit cache/data, dmenu fuzzy cache, configuration files
1923
2024 ### Changed
2125 - Dmenu performance dramatically improved: 5 seconds → 4ms (99.92% faster)
@@ -16,6 +16,10 @@ All notable changes to this project will be documented in this file.
16 - XDG Base Directory Specification compliance (cache stored in state directory)16 - XDG Base Directory Specification compliance (cache stored in state directory)
17 - Atomic operations for concurrent access control17 - Atomic operations for concurrent access control
18 - Dependency injection using interfaces and gomock for testing18 - Dependency injection using interfaces and gomock for testing
19+- Purge command for cleaning up dumber data and cache files:
20+ - Selective purging with flags: `--database`, `--cache`, `--webkit-data`, `--state`, `--config`
21+ - Confirmation prompt with `--force` flag to skip
22+ - Targets all data locations: SQLite database, WebKit cache/data, dmenu fuzzy cache, configuration files
19 23
20 ### Changed24 ### Changed
21 - Dmenu performance dramatically improved: 5 seconds → 4ms (99.92% faster)25 - Dmenu performance dramatically improved: 5 seconds → 4ms (99.92% faster)
M README.md +5 -0
@@ -103,6 +103,11 @@ Development:
103103 - Launcher integration (dmenu‑style examples):
104104 - rofi: `dumber dmenu | rofi -dmenu -p "Browse: " | dumber dmenu --select`
105105 - fuzzel: `dumber dmenu | fuzzel --dmenu -p "Browse: " | dumber dmenu --select`
106+- Clean up data and cache:
107+ - `dumber purge` # purge all data (with confirmation)
108+ - `dumber purge --force` # purge all data (no confirmation)
109+ - `dumber purge --database --cache` # purge only database and caches
110+ - `dumber purge --webkit-data` # purge only WebKit data (cookies, etc.)
106111
107112 ### Dmenu mode invocation
108113 You can invoke dmenu mode in two ways:
@@ -103,6 +103,11 @@ Development:
103 - Launcher integration (dmenu‑style examples):103 - Launcher integration (dmenu‑style examples):
104 - rofi: `dumber dmenu | rofi -dmenu -p "Browse: " | dumber dmenu --select`104 - rofi: `dumber dmenu | rofi -dmenu -p "Browse: " | dumber dmenu --select`
105 - fuzzel: `dumber dmenu | fuzzel --dmenu -p "Browse: " | dumber dmenu --select`105 - fuzzel: `dumber dmenu | fuzzel --dmenu -p "Browse: " | dumber dmenu --select`
106+- Clean up data and cache:
107+ - `dumber purge` # purge all data (with confirmation)
108+ - `dumber purge --force` # purge all data (no confirmation)
109+ - `dumber purge --database --cache` # purge only database and caches
110+ - `dumber purge --webkit-data` # purge only WebKit data (cookies, etc.)
106 111
107 ### Dmenu mode invocation112 ### Dmenu mode invocation
108 You can invoke dmenu mode in two ways:113 You can invoke dmenu mode in two ways:
A internal/cli/purge.go +263 -0
@@ -0,0 +1,263 @@
1+package cli
2+
3+import (
4+ "bufio"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+
10+ "github.com/bnema/dumber/internal/config"
11+ "github.com/spf13/cobra"
12+)
13+
14+// PurgeFlags holds all the purge command flags
15+type PurgeFlags struct {
16+ Database bool
17+ Cache bool
18+ State bool
19+ WebkitData bool
20+ Config bool
21+ All bool
22+ Force bool
23+}
24+
25+// NewPurgeCmd creates the purge command
26+func NewPurgeCmd() *cobra.Command {
27+ var flags PurgeFlags
28+
29+ cmd := &cobra.Command{
30+ Use: "purge",
31+ Short: "Purge dumber data and cache files",
32+ Long: `Purge various dumber data and cache files. By default, purges everything.
33+
34+Available purge targets:
35+ --database, -d Purge the SQLite database (history, shortcuts, zoom levels)
36+ --cache, -c Purge WebKit cache and dmenu fuzzy search cache
37+ --state, -s Purge all state data (includes database and caches)
38+ --webkit-data, -w Purge WebKit data (cookies, local storage, session data)
39+ --config Purge configuration files
40+ --all, -a Purge everything (default if no specific flags are provided)
41+
42+Use --force to skip the confirmation prompt.
43+
44+Examples:
45+ dumber purge # Purge everything (with confirmation)
46+ dumber purge --force # Purge everything (no confirmation)
47+ dumber purge -d -c # Purge only database and caches
48+ dumber purge --webkit-data -f # Force purge WebKit data only`,
49+ RunE: func(_ *cobra.Command, _ []string) error {
50+ // Initialize CLI to get config paths
51+ cli, err := NewCLI()
52+ if err != nil {
53+ return fmt.Errorf("failed to initialize CLI: %w", err)
54+ }
55+ defer func() {
56+ if closeErr := cli.Close(); closeErr != nil {
57+ fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr)
58+ }
59+ }()
60+
61+ return executePurge(flags)
62+ },
63+ }
64+
65+ // Add flags
66+ cmd.Flags().BoolVarP(&flags.Database, "database", "d", false, "Purge the SQLite database")
67+ cmd.Flags().BoolVarP(&flags.Cache, "cache", "c", false, "Purge WebKit cache and dmenu fuzzy cache")
68+ cmd.Flags().BoolVarP(&flags.State, "state", "s", false, "Purge all state data")
69+ cmd.Flags().BoolVarP(&flags.WebkitData, "webkit-data", "w", false, "Purge WebKit data (cookies, local storage)")
70+ cmd.Flags().BoolVar(&flags.Config, "config", false, "Purge configuration files")
71+ cmd.Flags().BoolVarP(&flags.All, "all", "a", false, "Purge everything")
72+ cmd.Flags().BoolVarP(&flags.Force, "force", "f", false, "Skip confirmation prompt")
73+
74+ return cmd
75+}
76+
77+// executePurge handles the purge logic
78+func executePurge(flags PurgeFlags) error {
79+ // Determine what to purge
80+ purgeItems := determinePurgeItems(flags)
81+ if len(purgeItems) == 0 {
82+ fmt.Println("Nothing to purge.")
83+ return nil
84+ }
85+
86+ // Get file paths for each item
87+ paths, err := getPurgePaths(purgeItems)
88+ if err != nil {
89+ return fmt.Errorf("failed to get purge paths: %w", err)
90+ }
91+
92+ // Show confirmation unless --force is used
93+ if !flags.Force {
94+ if !confirmPurge(purgeItems, paths) {
95+ fmt.Println("Purge cancelled.")
96+ return nil
97+ }
98+ }
99+
100+ // Execute the purge
101+ return performPurge(purgeItems, paths)
102+}
103+
104+// determinePurgeItems determines what should be purged based on flags
105+func determinePurgeItems(flags PurgeFlags) []string {
106+ var items []string
107+
108+ // If no specific flags are set, or --all is set, purge everything
109+ if flags.All || (!flags.Database && !flags.Cache && !flags.State && !flags.WebkitData && !flags.Config) {
110+ return []string{"database", "cache", "webkit-data", "config"}
111+ }
112+
113+ // Add items based on flags
114+ if flags.Database {
115+ items = append(items, "database")
116+ }
117+ if flags.Cache {
118+ items = append(items, "cache")
119+ }
120+ if flags.State {
121+ // State includes database and caches
122+ items = append(items, "database", "cache")
123+ }
124+ if flags.WebkitData {
125+ items = append(items, "webkit-data")
126+ }
127+ if flags.Config {
128+ items = append(items, "config")
129+ }
130+
131+ // Remove duplicates
132+ seen := make(map[string]bool)
133+ var result []string
134+ for _, item := range items {
135+ if !seen[item] {
136+ seen[item] = true
137+ result = append(result, item)
138+ }
139+ }
140+
141+ return result
142+}
143+
144+// getPurgePaths gets the file/directory paths for each purge item
145+func getPurgePaths(items []string) (map[string][]string, error) {
146+ paths := make(map[string][]string)
147+
148+ for _, item := range items {
149+ switch item {
150+ case "database":
151+ dbPath, err := config.GetDatabaseFile()
152+ if err != nil {
153+ return nil, fmt.Errorf("failed to get database path: %w", err)
154+ }
155+ paths[item] = []string{dbPath}
156+
157+ case "cache":
158+ var cachePaths []string
159+
160+ // WebKit cache
161+ stateDir, err := config.GetStateDir()
162+ if err != nil {
163+ return nil, fmt.Errorf("failed to get state directory: %w", err)
164+ }
165+ webkitCache := filepath.Join(stateDir, "webkit-cache")
166+ cachePaths = append(cachePaths, webkitCache)
167+
168+ // Dmenu fuzzy cache
169+ dmenuCache := filepath.Join(stateDir, "dmenu_fuzzy_cache.bin")
170+ cachePaths = append(cachePaths, dmenuCache)
171+
172+ paths[item] = cachePaths
173+
174+ case "webkit-data":
175+ dataDir, err := config.GetDataDir()
176+ if err != nil {
177+ return nil, fmt.Errorf("failed to get data directory: %w", err)
178+ }
179+ webkitData := filepath.Join(dataDir, "webkit")
180+ paths[item] = []string{webkitData}
181+
182+ case "config":
183+ configFile, err := config.GetConfigFile()
184+ if err != nil {
185+ return nil, fmt.Errorf("failed to get config file path: %w", err)
186+ }
187+ paths[item] = []string{configFile}
188+ }
189+ }
190+
191+ return paths, nil
192+}
193+
194+// confirmPurge shows a confirmation prompt and returns true if user confirms
195+func confirmPurge(items []string, paths map[string][]string) bool {
196+ fmt.Printf("This will delete the following:\n\n")
197+
198+ for _, item := range items {
199+ fmt.Printf("• %s:\n", strings.Title(item))
200+ for _, path := range paths[item] {
201+ // Check if path exists
202+ if _, err := os.Stat(path); err == nil {
203+ fmt.Printf(" - %s\n", path)
204+ } else {
205+ fmt.Printf(" - %s (not found)\n", path)
206+ }
207+ }
208+ }
209+
210+ fmt.Printf("\nAre you sure you want to continue? (y/N): ")
211+
212+ scanner := bufio.NewScanner(os.Stdin)
213+ if scanner.Scan() {
214+ response := strings.TrimSpace(strings.ToLower(scanner.Text()))
215+ return response == "y" || response == "yes"
216+ }
217+
218+ return false
219+}
220+
221+// performPurge executes the actual deletion
222+func performPurge(items []string, paths map[string][]string) error {
223+ var errors []string
224+
225+ for _, item := range items {
226+ fmt.Printf("Purging %s...\n", item)
227+
228+ for _, path := range paths[item] {
229+ if err := deletePath(path); err != nil {
230+ errors = append(errors, fmt.Sprintf("%s: %v", path, err))
231+ fmt.Printf(" ✗ Failed to delete %s: %v\n", path, err)
232+ } else {
233+ fmt.Printf(" ✓ Deleted %s\n", path)
234+ }
235+ }
236+ }
237+
238+ if len(errors) > 0 {
239+ return fmt.Errorf("some items could not be deleted:\n%s", strings.Join(errors, "\n"))
240+ }
241+
242+ fmt.Println("\nPurge completed successfully!")
243+ return nil
244+}
245+
246+// deletePath deletes a file or directory
247+func deletePath(path string) error {
248+ // Check if path exists
249+ info, err := os.Stat(path)
250+ if os.IsNotExist(err) {
251+ // Path doesn't exist, consider it successful
252+ return nil
253+ }
254+ if err != nil {
255+ return fmt.Errorf("failed to check path: %w", err)
256+ }
257+
258+ // Delete based on type
259+ if info.IsDir() {
260+ return os.RemoveAll(path)
261+ }
262+ return os.Remove(path)
263+}⏎̸
@@ -0,0 +1,263 @@
1+package cli
2+
3+import (
4+ "bufio"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+
10+ "github.com/bnema/dumber/internal/config"
11+ "github.com/spf13/cobra"
12+)
13+
14+// PurgeFlags holds all the purge command flags
15+type PurgeFlags struct {
16+ Database bool
17+ Cache bool
18+ State bool
19+ WebkitData bool
20+ Config bool
21+ All bool
22+ Force bool
23+}
24+
25+// NewPurgeCmd creates the purge command
26+func NewPurgeCmd() *cobra.Command {
27+ var flags PurgeFlags
28+
29+ cmd := &cobra.Command{
30+ Use: "purge",
31+ Short: "Purge dumber data and cache files",
32+ Long: `Purge various dumber data and cache files. By default, purges everything.
33+
34+Available purge targets:
35+ --database, -d Purge the SQLite database (history, shortcuts, zoom levels)
36+ --cache, -c Purge WebKit cache and dmenu fuzzy search cache
37+ --state, -s Purge all state data (includes database and caches)
38+ --webkit-data, -w Purge WebKit data (cookies, local storage, session data)
39+ --config Purge configuration files
40+ --all, -a Purge everything (default if no specific flags are provided)
41+
42+Use --force to skip the confirmation prompt.
43+
44+Examples:
45+ dumber purge # Purge everything (with confirmation)
46+ dumber purge --force # Purge everything (no confirmation)
47+ dumber purge -d -c # Purge only database and caches
48+ dumber purge --webkit-data -f # Force purge WebKit data only`,
49+ RunE: func(_ *cobra.Command, _ []string) error {
50+ // Initialize CLI to get config paths
51+ cli, err := NewCLI()
52+ if err != nil {
53+ return fmt.Errorf("failed to initialize CLI: %w", err)
54+ }
55+ defer func() {
56+ if closeErr := cli.Close(); closeErr != nil {
57+ fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr)
58+ }
59+ }()
60+
61+ return executePurge(flags)
62+ },
63+ }
64+
65+ // Add flags
66+ cmd.Flags().BoolVarP(&flags.Database, "database", "d", false, "Purge the SQLite database")
67+ cmd.Flags().BoolVarP(&flags.Cache, "cache", "c", false, "Purge WebKit cache and dmenu fuzzy cache")
68+ cmd.Flags().BoolVarP(&flags.State, "state", "s", false, "Purge all state data")
69+ cmd.Flags().BoolVarP(&flags.WebkitData, "webkit-data", "w", false, "Purge WebKit data (cookies, local storage)")
70+ cmd.Flags().BoolVar(&flags.Config, "config", false, "Purge configuration files")
71+ cmd.Flags().BoolVarP(&flags.All, "all", "a", false, "Purge everything")
72+ cmd.Flags().BoolVarP(&flags.Force, "force", "f", false, "Skip confirmation prompt")
73+
74+ return cmd
75+}
76+
77+// executePurge handles the purge logic
78+func executePurge(flags PurgeFlags) error {
79+ // Determine what to purge
80+ purgeItems := determinePurgeItems(flags)
81+ if len(purgeItems) == 0 {
82+ fmt.Println("Nothing to purge.")
83+ return nil
84+ }
85+
86+ // Get file paths for each item
87+ paths, err := getPurgePaths(purgeItems)
88+ if err != nil {
89+ return fmt.Errorf("failed to get purge paths: %w", err)
90+ }
91+
92+ // Show confirmation unless --force is used
93+ if !flags.Force {
94+ if !confirmPurge(purgeItems, paths) {
95+ fmt.Println("Purge cancelled.")
96+ return nil
97+ }
98+ }
99+
100+ // Execute the purge
101+ return performPurge(purgeItems, paths)
102+}
103+
104+// determinePurgeItems determines what should be purged based on flags
105+func determinePurgeItems(flags PurgeFlags) []string {
106+ var items []string
107+
108+ // If no specific flags are set, or --all is set, purge everything
109+ if flags.All || (!flags.Database && !flags.Cache && !flags.State && !flags.WebkitData && !flags.Config) {
110+ return []string{"database", "cache", "webkit-data", "config"}
111+ }
112+
113+ // Add items based on flags
114+ if flags.Database {
115+ items = append(items, "database")
116+ }
117+ if flags.Cache {
118+ items = append(items, "cache")
119+ }
120+ if flags.State {
121+ // State includes database and caches
122+ items = append(items, "database", "cache")
123+ }
124+ if flags.WebkitData {
125+ items = append(items, "webkit-data")
126+ }
127+ if flags.Config {
128+ items = append(items, "config")
129+ }
130+
131+ // Remove duplicates
132+ seen := make(map[string]bool)
133+ var result []string
134+ for _, item := range items {
135+ if !seen[item] {
136+ seen[item] = true
137+ result = append(result, item)
138+ }
139+ }
140+
141+ return result
142+}
143+
144+// getPurgePaths gets the file/directory paths for each purge item
145+func getPurgePaths(items []string) (map[string][]string, error) {
146+ paths := make(map[string][]string)
147+
148+ for _, item := range items {
149+ switch item {
150+ case "database":
151+ dbPath, err := config.GetDatabaseFile()
152+ if err != nil {
153+ return nil, fmt.Errorf("failed to get database path: %w", err)
154+ }
155+ paths[item] = []string{dbPath}
156+
157+ case "cache":
158+ var cachePaths []string
159+
160+ // WebKit cache
161+ stateDir, err := config.GetStateDir()
162+ if err != nil {
163+ return nil, fmt.Errorf("failed to get state directory: %w", err)
164+ }
165+ webkitCache := filepath.Join(stateDir, "webkit-cache")
166+ cachePaths = append(cachePaths, webkitCache)
167+
168+ // Dmenu fuzzy cache
169+ dmenuCache := filepath.Join(stateDir, "dmenu_fuzzy_cache.bin")
170+ cachePaths = append(cachePaths, dmenuCache)
171+
172+ paths[item] = cachePaths
173+
174+ case "webkit-data":
175+ dataDir, err := config.GetDataDir()
176+ if err != nil {
177+ return nil, fmt.Errorf("failed to get data directory: %w", err)
178+ }
179+ webkitData := filepath.Join(dataDir, "webkit")
180+ paths[item] = []string{webkitData}
181+
182+ case "config":
183+ configFile, err := config.GetConfigFile()
184+ if err != nil {
185+ return nil, fmt.Errorf("failed to get config file path: %w", err)
186+ }
187+ paths[item] = []string{configFile}
188+ }
189+ }
190+
191+ return paths, nil
192+}
193+
194+// confirmPurge shows a confirmation prompt and returns true if user confirms
195+func confirmPurge(items []string, paths map[string][]string) bool {
196+ fmt.Printf("This will delete the following:\n\n")
197+
198+ for _, item := range items {
199+ fmt.Printf("• %s:\n", strings.Title(item))
200+ for _, path := range paths[item] {
201+ // Check if path exists
202+ if _, err := os.Stat(path); err == nil {
203+ fmt.Printf(" - %s\n", path)
204+ } else {
205+ fmt.Printf(" - %s (not found)\n", path)
206+ }
207+ }
208+ }
209+
210+ fmt.Printf("\nAre you sure you want to continue? (y/N): ")
211+
212+ scanner := bufio.NewScanner(os.Stdin)
213+ if scanner.Scan() {
214+ response := strings.TrimSpace(strings.ToLower(scanner.Text()))
215+ return response == "y" || response == "yes"
216+ }
217+
218+ return false
219+}
220+
221+// performPurge executes the actual deletion
222+func performPurge(items []string, paths map[string][]string) error {
223+ var errors []string
224+
225+ for _, item := range items {
226+ fmt.Printf("Purging %s...\n", item)
227+
228+ for _, path := range paths[item] {
229+ if err := deletePath(path); err != nil {
230+ errors = append(errors, fmt.Sprintf("%s: %v", path, err))
231+ fmt.Printf(" ✗ Failed to delete %s: %v\n", path, err)
232+ } else {
233+ fmt.Printf(" ✓ Deleted %s\n", path)
234+ }
235+ }
236+ }
237+
238+ if len(errors) > 0 {
239+ return fmt.Errorf("some items could not be deleted:\n%s", strings.Join(errors, "\n"))
240+ }
241+
242+ fmt.Println("\nPurge completed successfully!")
243+ return nil
244+}
245+
246+// deletePath deletes a file or directory
247+func deletePath(path string) error {
248+ // Check if path exists
249+ info, err := os.Stat(path)
250+ if os.IsNotExist(err) {
251+ // Path doesn't exist, consider it successful
252+ return nil
253+ }
254+ if err != nil {
255+ return fmt.Errorf("failed to check path: %w", err)
256+ }
257+
258+ // Delete based on type
259+ if info.IsDir() {
260+ return os.RemoveAll(path)
261+ }
262+ return os.Remove(path)
263+}⏎̸
M internal/cli/root.go +1 -0
@@ -161,6 +161,7 @@ func NewRootCmd(version, commit, buildDate string) *cobra.Command {
161161 rootCmd.AddCommand(NewBrowseCmd())
162162 rootCmd.AddCommand(NewDmenuCmd())
163163 rootCmd.AddCommand(NewHistoryCmd())
164+ rootCmd.AddCommand(NewPurgeCmd())
164165
165166 return rootCmd
166167 }
@@ -161,6 +161,7 @@ func NewRootCmd(version, commit, buildDate string) *cobra.Command {
161 rootCmd.AddCommand(NewBrowseCmd())161 rootCmd.AddCommand(NewBrowseCmd())
162 rootCmd.AddCommand(NewDmenuCmd())162 rootCmd.AddCommand(NewDmenuCmd())
163 rootCmd.AddCommand(NewHistoryCmd())163 rootCmd.AddCommand(NewHistoryCmd())
164+ rootCmd.AddCommand(NewPurgeCmd())
164 165
165 return rootCmd166 return rootCmd
166 }167 }

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