detached 38db1f3

feat(cli): add config command and fix viper usage

- Add `dumber config` command to open config in $VISUAL/$EDITOR
- Add `--path` flag to print config file path
- Fix duplicate keys in config.json by using viper.SafeWriteConfigAs
- Fix config not re-reading newly created default file
- Remove config writing on every load/reload (bad practice)
commit
38db1f3d6f0f36de7726152f1c12d7e2eb110a3e
author
bamen · · 2025-10-07 18:37 UTC
committer
bamen · · 2025-10-07 18:45 UTC
parent
dc65ba3
signature
unsigned
4 files · +71 -70
M CHANGELOG.md +4 -0
@@ -7,10 +7,14 @@ All notable changes to this project will be documented in this file.
77 ### Added
88 - ENV=dev support to isolate test builds from production config/data in .dev/dumber/ directory
99 - Content filtering whitelist config (Twitch enabled by default)
10+- `dumber config` command to open config file in $VISUAL/$EDITOR or print path with `--path`
1011
1112 ### Fixed
1213 - Enabled missing WebKitGTK6 features: WebRTC, MediaSource, LocalStorage, WebAudio, MediaStream, Clipboard
1314 - Cosmetic filter duplicate injection in frames
15+- Config file duplicate keys (snake_case/camelCase) by using Viper's SafeWriteConfigAs
16+- Config not reading newly created default config file
17+- Config writing to disk on every load/reload
1418
1519 ## [0.11.0] - 2025-10-07
1620
@@ -7,10 +7,14 @@ All notable changes to this project will be documented in this file.
7 ### Added7 ### Added
8 - ENV=dev support to isolate test builds from production config/data in .dev/dumber/ directory8 - ENV=dev support to isolate test builds from production config/data in .dev/dumber/ directory
9 - Content filtering whitelist config (Twitch enabled by default)9 - Content filtering whitelist config (Twitch enabled by default)
10+- `dumber config` command to open config file in $VISUAL/$EDITOR or print path with `--path`
10 11
11 ### Fixed12 ### Fixed
12 - Enabled missing WebKitGTK6 features: WebRTC, MediaSource, LocalStorage, WebAudio, MediaStream, Clipboard13 - Enabled missing WebKitGTK6 features: WebRTC, MediaSource, LocalStorage, WebAudio, MediaStream, Clipboard
13 - Cosmetic filter duplicate injection in frames14 - Cosmetic filter duplicate injection in frames
15+- Config file duplicate keys (snake_case/camelCase) by using Viper's SafeWriteConfigAs
16+- Config not reading newly created default config file
17+- Config writing to disk on every load/reload
14 18
15 ## [0.11.0] - 2025-10-0719 ## [0.11.0] - 2025-10-07
16 20
A internal/cli/config.go +60 -0
@@ -0,0 +1,60 @@
1+package cli
2+
3+import (
4+ "fmt"
5+ "os"
6+ "os/exec"
7+
8+ "github.com/bnema/dumber/internal/config"
9+ "github.com/spf13/cobra"
10+)
11+
12+// NewConfigCmd creates the config command
13+func NewConfigCmd() *cobra.Command {
14+ cmd := &cobra.Command{
15+ Use: "config",
16+ Short: "Manage dumber configuration",
17+ Long: `Open the configuration file in your editor or print its path.`,
18+ RunE: openConfig,
19+ }
20+
21+ cmd.Flags().Bool("path", false, "Print the full path of the config file")
22+
23+ return cmd
24+}
25+
26+// openConfig opens the config file in the user's editor or prints its path
27+func openConfig(cmd *cobra.Command, _ []string) error {
28+ configPath, err := config.GetConfigFile()
29+ if err != nil {
30+ return fmt.Errorf("failed to get config file path: %w", err)
31+ }
32+
33+ // If --path flag is set, just print the path
34+ printPath, _ := cmd.Flags().GetBool("path")
35+ if printPath {
36+ fmt.Println(configPath)
37+ return nil
38+ }
39+
40+ // Get editor from environment (prefer $VISUAL, fallback to $EDITOR)
41+ editor := os.Getenv("VISUAL")
42+ if editor == "" {
43+ editor = os.Getenv("EDITOR")
44+ }
45+ if editor == "" {
46+ return fmt.Errorf("no editor defined: set $VISUAL or $EDITOR environment variable")
47+ }
48+
49+ // Open the config file in the editor
50+ editorCmd := exec.Command(editor, configPath)
51+ editorCmd.Stdin = os.Stdin
52+ editorCmd.Stdout = os.Stdout
53+ editorCmd.Stderr = os.Stderr
54+
55+ if err := editorCmd.Run(); err != nil {
56+ return fmt.Errorf("failed to open editor: %w", err)
57+ }
58+
59+ return nil
60+}
@@ -0,0 +1,60 @@
1+package cli
2+
3+import (
4+ "fmt"
5+ "os"
6+ "os/exec"
7+
8+ "github.com/bnema/dumber/internal/config"
9+ "github.com/spf13/cobra"
10+)
11+
12+// NewConfigCmd creates the config command
13+func NewConfigCmd() *cobra.Command {
14+ cmd := &cobra.Command{
15+ Use: "config",
16+ Short: "Manage dumber configuration",
17+ Long: `Open the configuration file in your editor or print its path.`,
18+ RunE: openConfig,
19+ }
20+
21+ cmd.Flags().Bool("path", false, "Print the full path of the config file")
22+
23+ return cmd
24+}
25+
26+// openConfig opens the config file in the user's editor or prints its path
27+func openConfig(cmd *cobra.Command, _ []string) error {
28+ configPath, err := config.GetConfigFile()
29+ if err != nil {
30+ return fmt.Errorf("failed to get config file path: %w", err)
31+ }
32+
33+ // If --path flag is set, just print the path
34+ printPath, _ := cmd.Flags().GetBool("path")
35+ if printPath {
36+ fmt.Println(configPath)
37+ return nil
38+ }
39+
40+ // Get editor from environment (prefer $VISUAL, fallback to $EDITOR)
41+ editor := os.Getenv("VISUAL")
42+ if editor == "" {
43+ editor = os.Getenv("EDITOR")
44+ }
45+ if editor == "" {
46+ return fmt.Errorf("no editor defined: set $VISUAL or $EDITOR environment variable")
47+ }
48+
49+ // Open the config file in the editor
50+ editorCmd := exec.Command(editor, configPath)
51+ editorCmd.Stdin = os.Stdin
52+ editorCmd.Stdout = os.Stdout
53+ editorCmd.Stderr = os.Stderr
54+
55+ if err := editorCmd.Run(); err != nil {
56+ return fmt.Errorf("failed to open editor: %w", err)
57+ }
58+
59+ return nil
60+}
M internal/cli/root.go +1 -0
@@ -188,6 +188,7 @@ func NewRootCmd(version, commit, buildDate string) *cobra.Command {
188188 rootCmd.AddCommand(NewHistoryCmd())
189189 rootCmd.AddCommand(NewPurgeCmd())
190190 rootCmd.AddCommand(NewLogsCmd())
191+ rootCmd.AddCommand(NewConfigCmd())
191192
192193 return rootCmd
193194 }
@@ -188,6 +188,7 @@ func NewRootCmd(version, commit, buildDate string) *cobra.Command {
188 rootCmd.AddCommand(NewHistoryCmd())188 rootCmd.AddCommand(NewHistoryCmd())
189 rootCmd.AddCommand(NewPurgeCmd())189 rootCmd.AddCommand(NewPurgeCmd())
190 rootCmd.AddCommand(NewLogsCmd())190 rootCmd.AddCommand(NewLogsCmd())
191+ rootCmd.AddCommand(NewConfigCmd())
191 192
192 return rootCmd193 return rootCmd
193 }194 }
M internal/config/config.go +6 -70
@@ -2,7 +2,6 @@
22 package config
33
44 import (
5- "encoding/json"
65 "errors"
76 "fmt"
87 "os"
@@ -472,6 +471,10 @@ func (m *Manager) Load() error {
472471 if err := m.createDefaultConfig(); err != nil {
473472 return fmt.Errorf("failed to create default config: %w", err)
474473 }
474+ // Re-read the newly created config file
475+ if err := m.viper.ReadInConfig(); err != nil {
476+ return fmt.Errorf("failed to read newly created config: %w", err)
477+ }
475478 } else {
476479 return fmt.Errorf("failed to read config file: %w", err)
477480 }
@@ -515,8 +518,6 @@ func (m *Manager) Load() error {
515518 // Validate and configure codec preferences
516519 config = m.validateAndConfigureCodecPreferences(config)
517520
518- m.ensurePersistedDefaults(config)
519-
520521 m.config = config
521522 return nil
522523 }
@@ -615,8 +616,6 @@ func (m *Manager) reload() error {
615616 // Validate and configure codec preferences
616617 config = m.validateAndConfigureCodecPreferences(config)
617618
618- m.ensurePersistedDefaults(config)
619-
620619 m.config = config
621620 return nil
622621 }
@@ -747,59 +746,6 @@ func (m *Manager) setDefaults() {
747746 m.viper.SetDefault("workspace.styling.border_radius", defaults.Workspace.Styling.BorderRadius)
748747 }
749748
750-func (m *Manager) ensurePersistedDefaults(cfg *Config) {
751- if cfg == nil {
752- return
753- }
754-
755- if m.viper == nil {
756- return
757- }
758-
759- // Only persist when the key is missing from the user config file.
760- if m.viper.InConfig("use_dom_zoom") {
761- return
762- }
763-
764- cfgFile := m.viper.ConfigFileUsed()
765- if cfgFile == "" {
766- return
767- }
768-
769- m.viper.Set("use_dom_zoom", cfg.UseDomZoom)
770- if err := m.viper.WriteConfig(); err != nil {
771- logging.Warn(fmt.Sprintf("Config: failed to persist use_dom_zoom default to %s: %v", cfgFile, err))
772- return
773- }
774-
775- logging.Info(fmt.Sprintf("Config: persisted missing use_dom_zoom default (%v) to %s", cfg.UseDomZoom, cfgFile))
776-}
777-
778-// persistDefaultZoomIfMissing persists the default_zoom value to the config file if it's not already set.
779-func (m *Manager) persistDefaultZoomIfMissing(cfg *Config) {
780- if cfg == nil {
781- return
782- }
783-
784- // Only persist when the key is missing from the user config file.
785- if m.viper.InConfig("default_zoom") {
786- return
787- }
788-
789- cfgFile := m.viper.ConfigFileUsed()
790- if cfgFile == "" {
791- return
792- }
793-
794- m.viper.Set("default_zoom", cfg.DefaultZoom)
795- if err := m.viper.WriteConfig(); err != nil {
796- logging.Warn(fmt.Sprintf("Config: failed to persist default_zoom default to %s: %v", cfgFile, err))
797- return
798- }
799-
800- logging.Info(fmt.Sprintf("Config: persisted missing default_zoom default (%.2f) to %s", cfg.DefaultZoom, cfgFile))
801-}
802-
803749 // createDefaultConfig creates a default configuration file.
804750 func (m *Manager) createDefaultConfig() error {
805751 configFile, err := GetConfigFile()
@@ -812,18 +758,8 @@ func (m *Manager) createDefaultConfig() error {
812758 return err
813759 }
814760
815- // Get the default configuration
816- defaultConfig := DefaultConfig()
817-
818- // Marshal to JSON with proper indentation
819- // Note: Go 1.20+ automatically sorts map keys in JSON output
820- configData, err := json.MarshalIndent(defaultConfig, "", " ")
821- if err != nil {
822- return fmt.Errorf("failed to marshal default config: %w", err)
823- }
824-
825- // Write JSON config file
826- if err := os.WriteFile(configFile, configData, filePerm); err != nil {
761+ // Use Viper's SafeWriteConfigAs to avoid duplicate keys
762+ if err := m.viper.SafeWriteConfigAs(configFile); err != nil {
827763 return fmt.Errorf("failed to write config file: %w", err)
828764 }
829765
@@ -2,7 +2,6 @@
2 package config2 package config
3 3
4 import (4 import (
5- "encoding/json"
6 "errors"5 "errors"
7 "fmt"6 "fmt"
8 "os"7 "os"
@@ -472,6 +471,10 @@ func (m *Manager) Load() error {
472 if err := m.createDefaultConfig(); err != nil {471 if err := m.createDefaultConfig(); err != nil {
473 return fmt.Errorf("failed to create default config: %w", err)472 return fmt.Errorf("failed to create default config: %w", err)
474 }473 }
474+ // Re-read the newly created config file
475+ if err := m.viper.ReadInConfig(); err != nil {
476+ return fmt.Errorf("failed to read newly created config: %w", err)
477+ }
475 } else {478 } else {
476 return fmt.Errorf("failed to read config file: %w", err)479 return fmt.Errorf("failed to read config file: %w", err)
477 }480 }
@@ -515,8 +518,6 @@ func (m *Manager) Load() error {
515 // Validate and configure codec preferences518 // Validate and configure codec preferences
516 config = m.validateAndConfigureCodecPreferences(config)519 config = m.validateAndConfigureCodecPreferences(config)
517 520
518- m.ensurePersistedDefaults(config)
519-
520 m.config = config521 m.config = config
521 return nil522 return nil
522 }523 }
@@ -615,8 +616,6 @@ func (m *Manager) reload() error {
615 // Validate and configure codec preferences616 // Validate and configure codec preferences
616 config = m.validateAndConfigureCodecPreferences(config)617 config = m.validateAndConfigureCodecPreferences(config)
617 618
618- m.ensurePersistedDefaults(config)
619-
620 m.config = config619 m.config = config
621 return nil620 return nil
622 }621 }
@@ -747,59 +746,6 @@ func (m *Manager) setDefaults() {
747 m.viper.SetDefault("workspace.styling.border_radius", defaults.Workspace.Styling.BorderRadius)746 m.viper.SetDefault("workspace.styling.border_radius", defaults.Workspace.Styling.BorderRadius)
748 }747 }
749 748
750-func (m *Manager) ensurePersistedDefaults(cfg *Config) {
751- if cfg == nil {
752- return
753- }
754-
755- if m.viper == nil {
756- return
757- }
758-
759- // Only persist when the key is missing from the user config file.
760- if m.viper.InConfig("use_dom_zoom") {
761- return
762- }
763-
764- cfgFile := m.viper.ConfigFileUsed()
765- if cfgFile == "" {
766- return
767- }
768-
769- m.viper.Set("use_dom_zoom", cfg.UseDomZoom)
770- if err := m.viper.WriteConfig(); err != nil {
771- logging.Warn(fmt.Sprintf("Config: failed to persist use_dom_zoom default to %s: %v", cfgFile, err))
772- return
773- }
774-
775- logging.Info(fmt.Sprintf("Config: persisted missing use_dom_zoom default (%v) to %s", cfg.UseDomZoom, cfgFile))
776-}
777-
778-// persistDefaultZoomIfMissing persists the default_zoom value to the config file if it's not already set.
779-func (m *Manager) persistDefaultZoomIfMissing(cfg *Config) {
780- if cfg == nil {
781- return
782- }
783-
784- // Only persist when the key is missing from the user config file.
785- if m.viper.InConfig("default_zoom") {
786- return
787- }
788-
789- cfgFile := m.viper.ConfigFileUsed()
790- if cfgFile == "" {
791- return
792- }
793-
794- m.viper.Set("default_zoom", cfg.DefaultZoom)
795- if err := m.viper.WriteConfig(); err != nil {
796- logging.Warn(fmt.Sprintf("Config: failed to persist default_zoom default to %s: %v", cfgFile, err))
797- return
798- }
799-
800- logging.Info(fmt.Sprintf("Config: persisted missing default_zoom default (%.2f) to %s", cfg.DefaultZoom, cfgFile))
801-}
802-
803 // createDefaultConfig creates a default configuration file.749 // createDefaultConfig creates a default configuration file.
804 func (m *Manager) createDefaultConfig() error {750 func (m *Manager) createDefaultConfig() error {
805 configFile, err := GetConfigFile()751 configFile, err := GetConfigFile()
@@ -812,18 +758,8 @@ func (m *Manager) createDefaultConfig() error {
812 return err758 return err
813 }759 }
814 760
815- // Get the default configuration761+ // Use Viper's SafeWriteConfigAs to avoid duplicate keys
816- defaultConfig := DefaultConfig()762+ if err := m.viper.SafeWriteConfigAs(configFile); err != nil {
817-
818- // Marshal to JSON with proper indentation
819- // Note: Go 1.20+ automatically sorts map keys in JSON output
820- configData, err := json.MarshalIndent(defaultConfig, "", " ")
821- if err != nil {
822- return fmt.Errorf("failed to marshal default config: %w", err)
823- }
824-
825- // Write JSON config file
826- if err := os.WriteFile(configFile, configData, filePerm); err != nil {
827 return fmt.Errorf("failed to write config file: %w", err)763 return fmt.Errorf("failed to write config file: %w", err)
828 }764 }
829 765

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