v0.23.2 bfd5eb4

config.go RawHistoryFolder

1package cmd
2
3import (
4	"context"
5	"fmt"
6	"os"
7	"os/exec"
8
9	"github.com/charmbracelet/bubbles/spinner"
10	tea "github.com/charmbracelet/bubbletea"
11	"github.com/charmbracelet/lipgloss"
12	"github.com/spf13/cobra"
13
14	"github.com/bnema/dumber/internal/application/port"
15	"github.com/bnema/dumber/internal/application/usecase"
16	"github.com/bnema/dumber/internal/cli/styles"
17	"github.com/bnema/dumber/internal/infrastructure/config"
18)
19
20var configYes bool
21
22var configCmd = &cobra.Command{
23	Use:   "config",
24	Short: "Manage configuration",
25	Long:  `View configuration status and migrate to add new default settings.`,
26}
27
28var configOpenCmd = &cobra.Command{
29	Use:   "open",
30	Short: "Open config file in your editor",
31	Long: `Opens the config file using your preferred editor.
32
33Editor selection order:
34  1. $EDITOR environment variable
35  2. $VISUAL environment variable
36  3. Fallback to: nano, vim, vi (first found)`,
37	RunE: runConfigOpen,
38}
39
40var configStatusCmd = &cobra.Command{
41	Use:   "status",
42	Short: "Show config file status and migration availability",
43	Long:  `Display the config file path and check if any new settings are available.`,
44	RunE:  runConfigStatus,
45}
46
47var configMigrateCmd = &cobra.Command{
48	Use:   "migrate",
49	Short: "Add missing default settings to config file",
50	Long: `Compares your config file with available defaults and adds any missing settings.
51
52Existing settings are never modified - only missing keys are added with default values.`,
53	RunE: runConfigMigrate,
54}
55
56var configSchemaJSON bool
57
58var configSchemaCmd = &cobra.Command{
59	Use:   "schema",
60	Short: "Show all config keys with types and valid values",
61	Long: `Display a reference of all configuration keys with their types, defaults,
62descriptions, and valid values.
63
64Use --json for machine-readable output.`,
65	RunE: runConfigSchema,
66}
67
68func init() {
69	rootCmd.AddCommand(configCmd)
70	configCmd.AddCommand(configOpenCmd)
71	configCmd.AddCommand(configStatusCmd)
72	configCmd.AddCommand(configMigrateCmd)
73	configCmd.AddCommand(configSchemaCmd)
74	configMigrateCmd.Flags().BoolVarP(&configYes, "yes", "y", false, "skip confirmation prompt")
75	configSchemaCmd.Flags().BoolVar(&configSchemaJSON, "json", false, "output as JSON")
76}
77
78// runConfigSchema displays all configuration keys with their metadata.
79func runConfigSchema(_ *cobra.Command, _ []string) error {
80	app := GetApp()
81	if app == nil {
82		return fmt.Errorf("app not initialized")
83	}
84
85	provider := config.NewSchemaProvider()
86	uc := usecase.NewGetConfigSchemaUseCase(provider)
87
88	ctx := context.Background()
89	result, err := uc.Execute(ctx, usecase.GetConfigSchemaInput{})
90	if err != nil {
91		return fmt.Errorf("get config schema: %w", err)
92	}
93
94	renderer := styles.NewConfigSchemaRenderer(app.Theme)
95
96	if configSchemaJSON {
97		output, err := renderer.RenderJSON(result.Keys)
98		if err != nil {
99			return err
100		}
101		fmt.Println(output)
102		return nil
103	}
104
105	fmt.Println(renderer.Render(result.Keys))
106	return nil
107}
108
109// runConfigOpen opens the config file in the user's preferred editor.
110func runConfigOpen(_ *cobra.Command, _ []string) error {
111	configFile, err := config.GetConfigFile()
112	if err != nil {
113		return fmt.Errorf("get config file: %w", err)
114	}
115
116	// Check if config file exists
117	if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) {
118		return fmt.Errorf("config file does not exist: %s", configFile)
119	}
120
121	editor := getEditor()
122	if editor == "" {
123		return fmt.Errorf("no editor found: set $EDITOR or $VISUAL environment variable")
124	}
125
126	cmd := exec.Command(editor, configFile)
127	cmd.Stdin = os.Stdin
128	cmd.Stdout = os.Stdout
129	cmd.Stderr = os.Stderr
130
131	return cmd.Run()
132}
133
134// getEditor returns the user's preferred editor.
135func getEditor() string {
136	// Check $EDITOR first
137	if editor := os.Getenv("EDITOR"); editor != "" {
138		return editor
139	}
140
141	// Then $VISUAL
142	if editor := os.Getenv("VISUAL"); editor != "" {
143		return editor
144	}
145
146	// Fallback to common editors
147	fallbacks := []string{"nano", "vim", "vi"}
148	for _, editor := range fallbacks {
149		if path, err := exec.LookPath(editor); err == nil {
150			return path
151		}
152	}
153
154	return ""
155}
156
157// runConfigStatus shows config file path and migration status.
158func runConfigStatus(_ *cobra.Command, _ []string) error {
159	app := GetApp()
160	if app == nil {
161		return fmt.Errorf("app not initialized")
162	}
163
164	renderer := styles.NewConfigRenderer(app.Theme)
165	migrator := config.NewMigrator()
166	diffFormatter := config.NewDiffFormatter()
167	uc := usecase.NewMigrateConfigUseCase(migrator, diffFormatter)
168
169	// Get config file path
170	configFile, err := config.GetConfigFile()
171	if err != nil {
172		fmt.Println(renderer.RenderError(err))
173		return nil
174	}
175
176	// Check if config file exists
177	if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) {
178		fmt.Println(renderer.RenderNoConfigFile(configFile))
179		return nil
180	}
181
182	// Check for migration
183	ctx := context.Background()
184	result, err := uc.Check(ctx, usecase.CheckConfigMigrationInput{})
185	if err != nil {
186		fmt.Println(renderer.RenderError(err))
187		return nil
188	}
189
190	if !result.NeedsMigration {
191		fmt.Println(renderer.RenderUpToDate(configFile))
192		return nil
193	}
194
195	// Show status with missing count
196	fmt.Println(renderer.RenderConfigInfo(configFile, len(result.MissingKeys)))
197	fmt.Println(renderer.RenderMigrateHint())
198
199	return nil
200}
201
202// runConfigMigrate runs the migration with optional confirmation.
203func runConfigMigrate(_ *cobra.Command, _ []string) error {
204	app := GetApp()
205	if app == nil {
206		return fmt.Errorf("app not initialized")
207	}
208
209	renderer := styles.NewConfigRenderer(app.Theme)
210	migrator := config.NewMigrator()
211	diffFormatter := config.NewDiffFormatter()
212	uc := usecase.NewMigrateConfigUseCase(migrator, diffFormatter)
213
214	// Get config file path
215	configFile, err := config.GetConfigFile()
216	if err != nil {
217		fmt.Println(renderer.RenderError(err))
218		return nil
219	}
220
221	// Check if config file exists
222	if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) {
223		fmt.Println(renderer.RenderNoConfigFile(configFile))
224		return nil
225	}
226
227	// Detect changes (includes renames, additions, and removals)
228	ctx := context.Background()
229	detectResult, err := uc.DetectChanges(ctx, usecase.DetectChangesInput{})
230	if err != nil {
231		fmt.Println(renderer.RenderError(err))
232		return nil
233	}
234
235	if !detectResult.HasChanges {
236		fmt.Println(renderer.RenderUpToDate(configFile))
237		return nil
238	}
239
240	// Show config info with change summary
241	fmt.Println(renderer.RenderConfigInfo(configFile, len(detectResult.Changes)))
242	fmt.Println(renderer.RenderChangesSummary(detectResult.Changes))
243	fmt.Println(renderer.RenderChanges(detectResult.Changes))
244
245	// If --yes flag, proceed without confirmation
246	if configYes {
247		return executeMigration(ctx, uc, renderer)
248	}
249
250	// Show confirmation dialog
251	return runMigrateWithConfirmation(ctx, uc, renderer, app.Theme, detectResult.Changes)
252}
253
254// executeMigration performs the actual migration.
255func executeMigration(ctx context.Context, uc *usecase.MigrateConfigUseCase, renderer *styles.ConfigRenderer) error {
256	result, err := uc.Execute(ctx, usecase.MigrateConfigInput{})
257	if err != nil {
258		fmt.Println(renderer.RenderError(err))
259		return nil
260	}
261
262	if len(result.AddedKeys) > 0 {
263		fmt.Println(renderer.RenderMigrationSuccess(len(result.AddedKeys), result.ConfigFile))
264	}
265
266	return nil
267}
268
269// migrateState represents the current state of the migrate confirmation.
270type migrateState int
271
272const (
273	migrateStateConfirm migrateState = iota
274	migrateStateDone
275)
276
277// migrateModel is the bubbletea model for the migrate confirmation.
278type migrateModel struct {
279	spinner  spinner.Model
280	renderer *styles.ConfigRenderer
281	confirm  styles.ConfirmModel
282	state    migrateState
283	uc       *usecase.MigrateConfigUseCase
284	changes  []port.KeyChange
285
286	result   string
287	err      error
288	quitting bool
289}
290
291// migrateResultMsg is sent when the migration completes.
292type migrateResultMsg struct {
293	output *usecase.MigrateConfigOutput
294	err    error
295}
296
297func newMigrateModel(
298	renderer *styles.ConfigRenderer,
299	theme *styles.Theme,
300	uc *usecase.MigrateConfigUseCase,
301	changes []port.KeyChange,
302) migrateModel {
303	s := spinner.New()
304	s.Spinner = spinner.Dot
305	s.Style = lipgloss.NewStyle().Foreground(theme.Accent)
306
307	confirm := styles.NewConfirm(theme, "Apply these changes?")
308
309	return migrateModel{
310		spinner:  s,
311		renderer: renderer,
312		confirm:  confirm,
313		state:    migrateStateConfirm,
314		uc:       uc,
315		changes:  changes,
316	}
317}
318
319func (m migrateModel) Init() tea.Cmd {
320	return m.spinner.Tick
321}
322
323func (m migrateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
324	switch msg := msg.(type) {
325	case tea.KeyMsg:
326		switch msg.String() {
327		case "ctrl+c":
328			m.quitting = true
329			return m, tea.Quit
330		}
331
332	case spinner.TickMsg:
333		var cmd tea.Cmd
334		m.spinner, cmd = m.spinner.Update(msg)
335		return m, cmd
336
337	case migrateResultMsg:
338		m.state = migrateStateDone
339		if msg.err != nil {
340			m.err = msg.err
341			return m, tea.Quit
342		}
343
344		if len(msg.output.AddedKeys) > 0 {
345			m.result = m.renderer.RenderMigrationSuccess(len(msg.output.AddedKeys), msg.output.ConfigFile)
346		}
347		return m, tea.Quit
348	}
349
350	// Handle confirm dialog
351	if m.state == migrateStateConfirm {
352		var cmd tea.Cmd
353		m.confirm, cmd = m.confirm.Update(msg)
354
355		if m.confirm.Done() {
356			if m.confirm.Result() {
357				// User confirmed, run migration
358				return m, m.runMigration()
359			}
360			// User canceled
361			m.quitting = true
362			return m, tea.Quit
363		}
364
365		return m, cmd
366	}
367
368	return m, nil
369}
370
371func (m migrateModel) View() string {
372	if m.quitting {
373		return ""
374	}
375
376	if m.err != nil {
377		return m.renderer.RenderError(m.err)
378	}
379
380	if m.state == migrateStateDone {
381		return m.result
382	}
383
384	// Show confirmation dialog
385	return m.confirm.View()
386}
387
388func (m migrateModel) runMigration() tea.Cmd {
389	return func() tea.Msg {
390		ctx := context.Background()
391		result, err := m.uc.Execute(ctx, usecase.MigrateConfigInput{})
392		return migrateResultMsg{output: result, err: err}
393	}
394}
395
396// runMigrateWithConfirmation runs the migrate with an interactive confirmation dialog.
397func runMigrateWithConfirmation(
398	ctx context.Context,
399	uc *usecase.MigrateConfigUseCase,
400	renderer *styles.ConfigRenderer,
401	theme *styles.Theme,
402	changes []port.KeyChange,
403) error {
404	// Suppress unused ctx warning - kept for consistency with other run functions
405	_ = ctx
406
407	m := newMigrateModel(renderer, theme, uc, changes)
408	p := tea.NewProgram(m)
409
410	_, err := p.Run()
411	if err != nil {
412		return fmt.Errorf("migration failed: %w", err)
413	}
414
415	return nil
416}

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