detached 8f1f165

feat(cli): add config command with migrate subcommand

commit
8f1f1655122e73309a690a18af8aadc10948acab
author
bnema · · 2025-12-27 18:48 UTC
parent
ffd31dd
signature
GPG Unverified2B82207D370127A0 The key is unknown
2 files · +439 -0
A internal/cli/cmd/config.go +299 -0
@@ -0,0 +1,299 @@
1+package cmd
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+
8+ "github.com/charmbracelet/bubbles/spinner"
9+ tea "github.com/charmbracelet/bubbletea"
10+ "github.com/charmbracelet/lipgloss"
11+ "github.com/spf13/cobra"
12+
13+ "github.com/bnema/dumber/internal/application/port"
14+ "github.com/bnema/dumber/internal/application/usecase"
15+ "github.com/bnema/dumber/internal/cli/styles"
16+ "github.com/bnema/dumber/internal/infrastructure/config"
17+)
18+
19+var configYes bool
20+
21+var configCmd = &cobra.Command{
22+ Use: "config",
23+ Short: "Manage configuration",
24+ Long: `View configuration status and migrate to add new default settings.`,
25+ RunE: runConfigStatus,
26+}
27+
28+var configMigrateCmd = &cobra.Command{
29+ Use: "migrate",
30+ Short: "Add missing default settings to config file",
31+ Long: `Compares your config file with available defaults and adds any missing settings.
32+
33+Existing settings are never modified - only missing keys are added with default values.`,
34+ RunE: runConfigMigrate,
35+}
36+
37+func init() {
38+ rootCmd.AddCommand(configCmd)
39+ configCmd.AddCommand(configMigrateCmd)
40+ configMigrateCmd.Flags().BoolVarP(&configYes, "yes", "y", false, "skip confirmation prompt")
41+}
42+
43+// runConfigStatus shows config file path and migration status.
44+func runConfigStatus(_ *cobra.Command, _ []string) error {
45+ app := GetApp()
46+ if app == nil {
47+ return fmt.Errorf("app not initialized")
48+ }
49+
50+ renderer := styles.NewConfigRenderer(app.Theme)
51+ migrator := config.NewMigrator()
52+ uc := usecase.NewMigrateConfigUseCase(migrator)
53+
54+ // Get config file path
55+ configFile, err := config.GetConfigFile()
56+ if err != nil {
57+ fmt.Println(renderer.RenderError(err))
58+ return nil
59+ }
60+
61+ // Check if config file exists
62+ if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) {
63+ fmt.Println(renderer.RenderNoConfigFile(configFile))
64+ return nil
65+ }
66+
67+ // Check for migration
68+ ctx := context.Background()
69+ result, err := uc.Check(ctx, usecase.CheckConfigMigrationInput{})
70+ if err != nil {
71+ fmt.Println(renderer.RenderError(err))
72+ return nil
73+ }
74+
75+ if !result.NeedsMigration {
76+ fmt.Println(renderer.RenderUpToDate(configFile))
77+ return nil
78+ }
79+
80+ // Show status with missing count
81+ fmt.Println(renderer.RenderConfigInfo(configFile, len(result.MissingKeys)))
82+ fmt.Println(renderer.RenderMigrateHint())
83+
84+ return nil
85+}
86+
87+// runConfigMigrate runs the migration with optional confirmation.
88+func runConfigMigrate(_ *cobra.Command, _ []string) error {
89+ app := GetApp()
90+ if app == nil {
91+ return fmt.Errorf("app not initialized")
92+ }
93+
94+ renderer := styles.NewConfigRenderer(app.Theme)
95+ migrator := config.NewMigrator()
96+ uc := usecase.NewMigrateConfigUseCase(migrator)
97+
98+ // Get config file path
99+ configFile, err := config.GetConfigFile()
100+ if err != nil {
101+ fmt.Println(renderer.RenderError(err))
102+ return nil
103+ }
104+
105+ // Check if config file exists
106+ if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) {
107+ fmt.Println(renderer.RenderNoConfigFile(configFile))
108+ return nil
109+ }
110+
111+ // Check for migration
112+ ctx := context.Background()
113+ result, err := uc.Check(ctx, usecase.CheckConfigMigrationInput{})
114+ if err != nil {
115+ fmt.Println(renderer.RenderError(err))
116+ return nil
117+ }
118+
119+ if !result.NeedsMigration {
120+ fmt.Println(renderer.RenderUpToDate(configFile))
121+ return nil
122+ }
123+
124+ // Show config info and missing keys
125+ fmt.Println(renderer.RenderConfigInfo(configFile, len(result.MissingKeys)))
126+ fmt.Println(renderer.RenderMissingKeys(result.MissingKeys))
127+
128+ // If --yes flag, proceed without confirmation
129+ if configYes {
130+ return executeMigration(ctx, uc, renderer)
131+ }
132+
133+ // Show confirmation dialog
134+ return runMigrateWithConfirmation(ctx, uc, renderer, app.Theme, result.MissingKeys)
135+}
136+
137+// executeMigration performs the actual migration.
138+func executeMigration(ctx context.Context, uc *usecase.MigrateConfigUseCase, renderer *styles.ConfigRenderer) error {
139+ result, err := uc.Execute(ctx, usecase.MigrateConfigInput{})
140+ if err != nil {
141+ fmt.Println(renderer.RenderError(err))
142+ return nil
143+ }
144+
145+ if len(result.AddedKeys) > 0 {
146+ fmt.Println(renderer.RenderMigrationSuccess(len(result.AddedKeys), result.ConfigFile))
147+ }
148+
149+ return nil
150+}
151+
152+// migrateState represents the current state of the migrate confirmation.
153+type migrateState int
154+
155+const (
156+ migrateStateConfirm migrateState = iota
157+ migrateStateDone
158+)
159+
160+// migrateModel is the bubbletea model for the migrate confirmation.
161+type migrateModel struct {
162+ spinner spinner.Model
163+ renderer *styles.ConfigRenderer
164+ confirm styles.ConfirmModel
165+ state migrateState
166+ uc *usecase.MigrateConfigUseCase
167+ missingKeys []port.KeyInfo
168+
169+ result string
170+ err error
171+ quitting bool
172+}
173+
174+// migrateResultMsg is sent when the migration completes.
175+type migrateResultMsg struct {
176+ output *usecase.MigrateConfigOutput
177+ err error
178+}
179+
180+func newMigrateModel(
181+ renderer *styles.ConfigRenderer,
182+ theme *styles.Theme,
183+ uc *usecase.MigrateConfigUseCase,
184+ missingKeys []port.KeyInfo,
185+) migrateModel {
186+ s := spinner.New()
187+ s.Spinner = spinner.Dot
188+ s.Style = lipgloss.NewStyle().Foreground(theme.Accent)
189+
190+ confirm := styles.NewConfirm(theme, "Add these settings with default values?")
191+
192+ return migrateModel{
193+ spinner: s,
194+ renderer: renderer,
195+ confirm: confirm,
196+ state: migrateStateConfirm,
197+ uc: uc,
198+ missingKeys: missingKeys,
199+ }
200+}
201+
202+func (m migrateModel) Init() tea.Cmd {
203+ return m.spinner.Tick
204+}
205+
206+func (m migrateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
207+ switch msg := msg.(type) {
208+ case tea.KeyMsg:
209+ switch msg.String() {
210+ case "ctrl+c":
211+ m.quitting = true
212+ return m, tea.Quit
213+ }
214+
215+ case spinner.TickMsg:
216+ var cmd tea.Cmd
217+ m.spinner, cmd = m.spinner.Update(msg)
218+ return m, cmd
219+
220+ case migrateResultMsg:
221+ m.state = migrateStateDone
222+ if msg.err != nil {
223+ m.err = msg.err
224+ return m, tea.Quit
225+ }
226+
227+ if len(msg.output.AddedKeys) > 0 {
228+ m.result = m.renderer.RenderMigrationSuccess(len(msg.output.AddedKeys), msg.output.ConfigFile)
229+ }
230+ return m, tea.Quit
231+ }
232+
233+ // Handle confirm dialog
234+ if m.state == migrateStateConfirm {
235+ var cmd tea.Cmd
236+ m.confirm, cmd = m.confirm.Update(msg)
237+
238+ if m.confirm.Done() {
239+ if m.confirm.Result() {
240+ // User confirmed, run migration
241+ return m, m.runMigration()
242+ }
243+ // User canceled
244+ m.quitting = true
245+ return m, tea.Quit
246+ }
247+
248+ return m, cmd
249+ }
250+
251+ return m, nil
252+}
253+
254+func (m migrateModel) View() string {
255+ if m.quitting {
256+ return ""
257+ }
258+
259+ if m.err != nil {
260+ return m.renderer.RenderError(m.err)
261+ }
262+
263+ if m.state == migrateStateDone {
264+ return m.result
265+ }
266+
267+ // Show confirmation dialog
268+ return m.confirm.View()
269+}
270+
271+func (m migrateModel) runMigration() tea.Cmd {
272+ return func() tea.Msg {
273+ ctx := context.Background()
274+ result, err := m.uc.Execute(ctx, usecase.MigrateConfigInput{})
275+ return migrateResultMsg{output: result, err: err}
276+ }
277+}
278+
279+// runMigrateWithConfirmation runs the migrate with an interactive confirmation dialog.
280+func runMigrateWithConfirmation(
281+ ctx context.Context,
282+ uc *usecase.MigrateConfigUseCase,
283+ renderer *styles.ConfigRenderer,
284+ theme *styles.Theme,
285+ missingKeys []port.KeyInfo,
286+) error {
287+ // Suppress unused ctx warning - kept for consistency with other run functions
288+ _ = ctx
289+
290+ m := newMigrateModel(renderer, theme, uc, missingKeys)
291+ p := tea.NewProgram(m)
292+
293+ _, err := p.Run()
294+ if err != nil {
295+ return fmt.Errorf("migration failed: %w", err)
296+ }
297+
298+ return nil
299+}
@@ -0,0 +1,299 @@
1+package cmd
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+
8+ "github.com/charmbracelet/bubbles/spinner"
9+ tea "github.com/charmbracelet/bubbletea"
10+ "github.com/charmbracelet/lipgloss"
11+ "github.com/spf13/cobra"
12+
13+ "github.com/bnema/dumber/internal/application/port"
14+ "github.com/bnema/dumber/internal/application/usecase"
15+ "github.com/bnema/dumber/internal/cli/styles"
16+ "github.com/bnema/dumber/internal/infrastructure/config"
17+)
18+
19+var configYes bool
20+
21+var configCmd = &cobra.Command{
22+ Use: "config",
23+ Short: "Manage configuration",
24+ Long: `View configuration status and migrate to add new default settings.`,
25+ RunE: runConfigStatus,
26+}
27+
28+var configMigrateCmd = &cobra.Command{
29+ Use: "migrate",
30+ Short: "Add missing default settings to config file",
31+ Long: `Compares your config file with available defaults and adds any missing settings.
32+
33+Existing settings are never modified - only missing keys are added with default values.`,
34+ RunE: runConfigMigrate,
35+}
36+
37+func init() {
38+ rootCmd.AddCommand(configCmd)
39+ configCmd.AddCommand(configMigrateCmd)
40+ configMigrateCmd.Flags().BoolVarP(&configYes, "yes", "y", false, "skip confirmation prompt")
41+}
42+
43+// runConfigStatus shows config file path and migration status.
44+func runConfigStatus(_ *cobra.Command, _ []string) error {
45+ app := GetApp()
46+ if app == nil {
47+ return fmt.Errorf("app not initialized")
48+ }
49+
50+ renderer := styles.NewConfigRenderer(app.Theme)
51+ migrator := config.NewMigrator()
52+ uc := usecase.NewMigrateConfigUseCase(migrator)
53+
54+ // Get config file path
55+ configFile, err := config.GetConfigFile()
56+ if err != nil {
57+ fmt.Println(renderer.RenderError(err))
58+ return nil
59+ }
60+
61+ // Check if config file exists
62+ if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) {
63+ fmt.Println(renderer.RenderNoConfigFile(configFile))
64+ return nil
65+ }
66+
67+ // Check for migration
68+ ctx := context.Background()
69+ result, err := uc.Check(ctx, usecase.CheckConfigMigrationInput{})
70+ if err != nil {
71+ fmt.Println(renderer.RenderError(err))
72+ return nil
73+ }
74+
75+ if !result.NeedsMigration {
76+ fmt.Println(renderer.RenderUpToDate(configFile))
77+ return nil
78+ }
79+
80+ // Show status with missing count
81+ fmt.Println(renderer.RenderConfigInfo(configFile, len(result.MissingKeys)))
82+ fmt.Println(renderer.RenderMigrateHint())
83+
84+ return nil
85+}
86+
87+// runConfigMigrate runs the migration with optional confirmation.
88+func runConfigMigrate(_ *cobra.Command, _ []string) error {
89+ app := GetApp()
90+ if app == nil {
91+ return fmt.Errorf("app not initialized")
92+ }
93+
94+ renderer := styles.NewConfigRenderer(app.Theme)
95+ migrator := config.NewMigrator()
96+ uc := usecase.NewMigrateConfigUseCase(migrator)
97+
98+ // Get config file path
99+ configFile, err := config.GetConfigFile()
100+ if err != nil {
101+ fmt.Println(renderer.RenderError(err))
102+ return nil
103+ }
104+
105+ // Check if config file exists
106+ if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) {
107+ fmt.Println(renderer.RenderNoConfigFile(configFile))
108+ return nil
109+ }
110+
111+ // Check for migration
112+ ctx := context.Background()
113+ result, err := uc.Check(ctx, usecase.CheckConfigMigrationInput{})
114+ if err != nil {
115+ fmt.Println(renderer.RenderError(err))
116+ return nil
117+ }
118+
119+ if !result.NeedsMigration {
120+ fmt.Println(renderer.RenderUpToDate(configFile))
121+ return nil
122+ }
123+
124+ // Show config info and missing keys
125+ fmt.Println(renderer.RenderConfigInfo(configFile, len(result.MissingKeys)))
126+ fmt.Println(renderer.RenderMissingKeys(result.MissingKeys))
127+
128+ // If --yes flag, proceed without confirmation
129+ if configYes {
130+ return executeMigration(ctx, uc, renderer)
131+ }
132+
133+ // Show confirmation dialog
134+ return runMigrateWithConfirmation(ctx, uc, renderer, app.Theme, result.MissingKeys)
135+}
136+
137+// executeMigration performs the actual migration.
138+func executeMigration(ctx context.Context, uc *usecase.MigrateConfigUseCase, renderer *styles.ConfigRenderer) error {
139+ result, err := uc.Execute(ctx, usecase.MigrateConfigInput{})
140+ if err != nil {
141+ fmt.Println(renderer.RenderError(err))
142+ return nil
143+ }
144+
145+ if len(result.AddedKeys) > 0 {
146+ fmt.Println(renderer.RenderMigrationSuccess(len(result.AddedKeys), result.ConfigFile))
147+ }
148+
149+ return nil
150+}
151+
152+// migrateState represents the current state of the migrate confirmation.
153+type migrateState int
154+
155+const (
156+ migrateStateConfirm migrateState = iota
157+ migrateStateDone
158+)
159+
160+// migrateModel is the bubbletea model for the migrate confirmation.
161+type migrateModel struct {
162+ spinner spinner.Model
163+ renderer *styles.ConfigRenderer
164+ confirm styles.ConfirmModel
165+ state migrateState
166+ uc *usecase.MigrateConfigUseCase
167+ missingKeys []port.KeyInfo
168+
169+ result string
170+ err error
171+ quitting bool
172+}
173+
174+// migrateResultMsg is sent when the migration completes.
175+type migrateResultMsg struct {
176+ output *usecase.MigrateConfigOutput
177+ err error
178+}
179+
180+func newMigrateModel(
181+ renderer *styles.ConfigRenderer,
182+ theme *styles.Theme,
183+ uc *usecase.MigrateConfigUseCase,
184+ missingKeys []port.KeyInfo,
185+) migrateModel {
186+ s := spinner.New()
187+ s.Spinner = spinner.Dot
188+ s.Style = lipgloss.NewStyle().Foreground(theme.Accent)
189+
190+ confirm := styles.NewConfirm(theme, "Add these settings with default values?")
191+
192+ return migrateModel{
193+ spinner: s,
194+ renderer: renderer,
195+ confirm: confirm,
196+ state: migrateStateConfirm,
197+ uc: uc,
198+ missingKeys: missingKeys,
199+ }
200+}
201+
202+func (m migrateModel) Init() tea.Cmd {
203+ return m.spinner.Tick
204+}
205+
206+func (m migrateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
207+ switch msg := msg.(type) {
208+ case tea.KeyMsg:
209+ switch msg.String() {
210+ case "ctrl+c":
211+ m.quitting = true
212+ return m, tea.Quit
213+ }
214+
215+ case spinner.TickMsg:
216+ var cmd tea.Cmd
217+ m.spinner, cmd = m.spinner.Update(msg)
218+ return m, cmd
219+
220+ case migrateResultMsg:
221+ m.state = migrateStateDone
222+ if msg.err != nil {
223+ m.err = msg.err
224+ return m, tea.Quit
225+ }
226+
227+ if len(msg.output.AddedKeys) > 0 {
228+ m.result = m.renderer.RenderMigrationSuccess(len(msg.output.AddedKeys), msg.output.ConfigFile)
229+ }
230+ return m, tea.Quit
231+ }
232+
233+ // Handle confirm dialog
234+ if m.state == migrateStateConfirm {
235+ var cmd tea.Cmd
236+ m.confirm, cmd = m.confirm.Update(msg)
237+
238+ if m.confirm.Done() {
239+ if m.confirm.Result() {
240+ // User confirmed, run migration
241+ return m, m.runMigration()
242+ }
243+ // User canceled
244+ m.quitting = true
245+ return m, tea.Quit
246+ }
247+
248+ return m, cmd
249+ }
250+
251+ return m, nil
252+}
253+
254+func (m migrateModel) View() string {
255+ if m.quitting {
256+ return ""
257+ }
258+
259+ if m.err != nil {
260+ return m.renderer.RenderError(m.err)
261+ }
262+
263+ if m.state == migrateStateDone {
264+ return m.result
265+ }
266+
267+ // Show confirmation dialog
268+ return m.confirm.View()
269+}
270+
271+func (m migrateModel) runMigration() tea.Cmd {
272+ return func() tea.Msg {
273+ ctx := context.Background()
274+ result, err := m.uc.Execute(ctx, usecase.MigrateConfigInput{})
275+ return migrateResultMsg{output: result, err: err}
276+ }
277+}
278+
279+// runMigrateWithConfirmation runs the migrate with an interactive confirmation dialog.
280+func runMigrateWithConfirmation(
281+ ctx context.Context,
282+ uc *usecase.MigrateConfigUseCase,
283+ renderer *styles.ConfigRenderer,
284+ theme *styles.Theme,
285+ missingKeys []port.KeyInfo,
286+) error {
287+ // Suppress unused ctx warning - kept for consistency with other run functions
288+ _ = ctx
289+
290+ m := newMigrateModel(renderer, theme, uc, missingKeys)
291+ p := tea.NewProgram(m)
292+
293+ _, err := p.Run()
294+ if err != nil {
295+ return fmt.Errorf("migration failed: %w", err)
296+ }
297+
298+ return nil
299+}
A internal/cli/styles/config.go +140 -0
@@ -0,0 +1,140 @@
1+package styles
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/charmbracelet/lipgloss"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+)
11+
12+// ConfigRenderer renders config status messages with styled output.
13+type ConfigRenderer struct {
14+ theme *Theme
15+}
16+
17+// NewConfigRenderer creates a new config renderer with the given theme.
18+func NewConfigRenderer(theme *Theme) *ConfigRenderer {
19+ return &ConfigRenderer{theme: theme}
20+}
21+
22+// RenderConfigInfo renders the config file info with status.
23+func (r *ConfigRenderer) RenderConfigInfo(path string, missingCount int) string {
24+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent)
25+ pathStyle := r.theme.Subtle
26+
27+ var status string
28+ if missingCount > 0 {
29+ countStyle := lipgloss.NewStyle().Foreground(r.theme.Warning)
30+ status = fmt.Sprintf("\n %s %s new settings available",
31+ iconStyle.Render(IconInfo),
32+ countStyle.Render(fmt.Sprintf("%d", missingCount)),
33+ )
34+ }
35+
36+ return fmt.Sprintf(
37+ "\n %s Config %s%s\n",
38+ iconStyle.Render(IconConfig),
39+ pathStyle.Render(path),
40+ status,
41+ )
42+}
43+
44+// RenderMissingKeys renders the list of missing keys with their types and default values.
45+func (r *ConfigRenderer) RenderMissingKeys(keys []port.KeyInfo) string {
46+ if len(keys) == 0 {
47+ return ""
48+ }
49+
50+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent)
51+ keyStyle := r.theme.Highlight
52+ typeStyle := r.theme.Subtle
53+ valueStyle := lipgloss.NewStyle().Foreground(r.theme.Text)
54+
55+ var sb strings.Builder
56+ sb.WriteString(fmt.Sprintf("\n Missing settings (%d):\n", len(keys)))
57+
58+ for _, key := range keys {
59+ sb.WriteString(fmt.Sprintf(
60+ " %s %s\n Type: %s | Default: %s\n",
61+ iconStyle.Render(IconCursor),
62+ keyStyle.Render(key.Key),
63+ typeStyle.Render(key.Type),
64+ valueStyle.Render(key.DefaultValue),
65+ ))
66+ }
67+
68+ return sb.String()
69+}
70+
71+// RenderMigrationSuccess renders the success message after migration.
72+func (r *ConfigRenderer) RenderMigrationSuccess(count int, path string) string {
73+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Success)
74+ countStyle := r.theme.Highlight
75+ pathStyle := r.theme.Subtle
76+
77+ // Get just the filename from the path
78+ parts := strings.Split(path, "/")
79+ filename := parts[len(parts)-1]
80+
81+ return fmt.Sprintf(
82+ "\n %s Added %s new settings to %s\n",
83+ iconStyle.Render(IconCheck),
84+ countStyle.Render(fmt.Sprintf("%d", count)),
85+ pathStyle.Render(filename),
86+ )
87+}
88+
89+// RenderUpToDate renders the "config is up to date" message.
90+func (r *ConfigRenderer) RenderUpToDate(path string) string {
91+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Success)
92+ pathStyle := r.theme.Subtle
93+
94+ return fmt.Sprintf(
95+ "\n %s Config %s\n %s Config is up to date\n",
96+ iconStyle.Render(IconConfig),
97+ pathStyle.Render(path),
98+ iconStyle.Render(IconCheck),
99+ )
100+}
101+
102+// RenderError renders an error message.
103+func (r *ConfigRenderer) RenderError(err error) string {
104+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Error)
105+
106+ return fmt.Sprintf(
107+ "\n %s Config error: %v\n",
108+ iconStyle.Render(IconX),
109+ err,
110+ )
111+}
112+
113+// RenderChecking renders the "checking config..." message with spinner.
114+func (*ConfigRenderer) RenderChecking(spinner string) string {
115+ return fmt.Sprintf("\n %s Checking config...\n", spinner)
116+}
117+
118+// RenderMigrateHint renders a hint to run the migrate command.
119+func (r *ConfigRenderer) RenderMigrateHint() string {
120+ hintStyle := r.theme.Subtle
121+
122+ return fmt.Sprintf(
123+ "\n %s\n",
124+ hintStyle.Render("Run 'dumber config migrate' to add missing defaults."),
125+ )
126+}
127+
128+// RenderNoConfigFile renders message when config file doesn't exist yet.
129+func (r *ConfigRenderer) RenderNoConfigFile(path string) string {
130+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent)
131+ pathStyle := r.theme.Subtle
132+ hintStyle := r.theme.Subtle
133+
134+ return fmt.Sprintf(
135+ "\n %s Config %s\n %s\n",
136+ iconStyle.Render(IconConfig),
137+ pathStyle.Render(path),
138+ hintStyle.Render("Config file will be created on first run with all defaults."),
139+ )
140+}
@@ -0,0 +1,140 @@
1+package styles
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/charmbracelet/lipgloss"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+)
11+
12+// ConfigRenderer renders config status messages with styled output.
13+type ConfigRenderer struct {
14+ theme *Theme
15+}
16+
17+// NewConfigRenderer creates a new config renderer with the given theme.
18+func NewConfigRenderer(theme *Theme) *ConfigRenderer {
19+ return &ConfigRenderer{theme: theme}
20+}
21+
22+// RenderConfigInfo renders the config file info with status.
23+func (r *ConfigRenderer) RenderConfigInfo(path string, missingCount int) string {
24+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent)
25+ pathStyle := r.theme.Subtle
26+
27+ var status string
28+ if missingCount > 0 {
29+ countStyle := lipgloss.NewStyle().Foreground(r.theme.Warning)
30+ status = fmt.Sprintf("\n %s %s new settings available",
31+ iconStyle.Render(IconInfo),
32+ countStyle.Render(fmt.Sprintf("%d", missingCount)),
33+ )
34+ }
35+
36+ return fmt.Sprintf(
37+ "\n %s Config %s%s\n",
38+ iconStyle.Render(IconConfig),
39+ pathStyle.Render(path),
40+ status,
41+ )
42+}
43+
44+// RenderMissingKeys renders the list of missing keys with their types and default values.
45+func (r *ConfigRenderer) RenderMissingKeys(keys []port.KeyInfo) string {
46+ if len(keys) == 0 {
47+ return ""
48+ }
49+
50+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent)
51+ keyStyle := r.theme.Highlight
52+ typeStyle := r.theme.Subtle
53+ valueStyle := lipgloss.NewStyle().Foreground(r.theme.Text)
54+
55+ var sb strings.Builder
56+ sb.WriteString(fmt.Sprintf("\n Missing settings (%d):\n", len(keys)))
57+
58+ for _, key := range keys {
59+ sb.WriteString(fmt.Sprintf(
60+ " %s %s\n Type: %s | Default: %s\n",
61+ iconStyle.Render(IconCursor),
62+ keyStyle.Render(key.Key),
63+ typeStyle.Render(key.Type),
64+ valueStyle.Render(key.DefaultValue),
65+ ))
66+ }
67+
68+ return sb.String()
69+}
70+
71+// RenderMigrationSuccess renders the success message after migration.
72+func (r *ConfigRenderer) RenderMigrationSuccess(count int, path string) string {
73+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Success)
74+ countStyle := r.theme.Highlight
75+ pathStyle := r.theme.Subtle
76+
77+ // Get just the filename from the path
78+ parts := strings.Split(path, "/")
79+ filename := parts[len(parts)-1]
80+
81+ return fmt.Sprintf(
82+ "\n %s Added %s new settings to %s\n",
83+ iconStyle.Render(IconCheck),
84+ countStyle.Render(fmt.Sprintf("%d", count)),
85+ pathStyle.Render(filename),
86+ )
87+}
88+
89+// RenderUpToDate renders the "config is up to date" message.
90+func (r *ConfigRenderer) RenderUpToDate(path string) string {
91+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Success)
92+ pathStyle := r.theme.Subtle
93+
94+ return fmt.Sprintf(
95+ "\n %s Config %s\n %s Config is up to date\n",
96+ iconStyle.Render(IconConfig),
97+ pathStyle.Render(path),
98+ iconStyle.Render(IconCheck),
99+ )
100+}
101+
102+// RenderError renders an error message.
103+func (r *ConfigRenderer) RenderError(err error) string {
104+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Error)
105+
106+ return fmt.Sprintf(
107+ "\n %s Config error: %v\n",
108+ iconStyle.Render(IconX),
109+ err,
110+ )
111+}
112+
113+// RenderChecking renders the "checking config..." message with spinner.
114+func (*ConfigRenderer) RenderChecking(spinner string) string {
115+ return fmt.Sprintf("\n %s Checking config...\n", spinner)
116+}
117+
118+// RenderMigrateHint renders a hint to run the migrate command.
119+func (r *ConfigRenderer) RenderMigrateHint() string {
120+ hintStyle := r.theme.Subtle
121+
122+ return fmt.Sprintf(
123+ "\n %s\n",
124+ hintStyle.Render("Run 'dumber config migrate' to add missing defaults."),
125+ )
126+}
127+
128+// RenderNoConfigFile renders message when config file doesn't exist yet.
129+func (r *ConfigRenderer) RenderNoConfigFile(path string) string {
130+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent)
131+ pathStyle := r.theme.Subtle
132+ hintStyle := r.theme.Subtle
133+
134+ return fmt.Sprintf(
135+ "\n %s Config %s\n %s\n",
136+ iconStyle.Render(IconConfig),
137+ pathStyle.Render(path),
138+ hintStyle.Render("Config file will be created on first run with all defaults."),
139+ )
140+}

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