v0.27.2 5960dd8

config.go RawHistoryFolder

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

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