main 5041353

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	// Structural migration: move old [rendering]/[performance]/[privacy]/[runtime]
251	// sections to [engine]/[engine.webkit] if needed.
252	engineMigrated, engineErr := config.MigrateToEngineConfig(configFile)
253	if engineErr != nil {
254		fmt.Println(renderer.RenderError(fmt.Errorf("engine config migration failed: %w", engineErr)))
255		return nil
256	}
257	if engineMigrated {
258		fmt.Print(renderer.RenderEngineMigrationSuccess())
259	}
260
261	// Detect changes (includes renames, additions, and removals)
262	ctx := context.Background()
263	detectResult, err := uc.DetectChanges(ctx, usecase.DetectChangesInput{})
264	if err != nil {
265		fmt.Println(renderer.RenderError(err))
266		return nil
267	}
268
269	if !detectResult.HasChanges {
270		fmt.Println(renderer.RenderUpToDate(configFile))
271		return nil
272	}
273
274	// Show config info with change summary
275	fmt.Println(renderer.RenderConfigInfo(configFile, len(detectResult.Changes)))
276	fmt.Println(renderer.RenderChangesSummary(detectResult.Changes))
277	fmt.Println(renderer.RenderChanges(detectResult.Changes))
278
279	// If --yes flag, proceed without confirmation
280	if configYes {
281		return executeMigration(ctx, uc, renderer)
282	}
283
284	// Show confirmation dialog
285	return runMigrateWithConfirmation(ctx, uc, renderer, app.Theme, detectResult.Changes)
286}
287
288// executeMigration performs the actual migration.
289func executeMigration(ctx context.Context, uc *usecase.MigrateConfigUseCase, renderer *styles.ConfigRenderer) error {
290	result, err := uc.Execute(ctx, usecase.MigrateConfigInput{})
291	if err != nil {
292		fmt.Println(renderer.RenderError(err))
293		return nil
294	}
295
296	if len(result.AddedKeys) > 0 {
297		fmt.Println(renderer.RenderMigrationSuccess(len(result.AddedKeys), result.ConfigFile))
298	}
299
300	return nil
301}
302
303// migrateState represents the current state of the migrate confirmation.
304type migrateState int
305
306const (
307	migrateStateConfirm migrateState = iota
308	migrateStateDone
309)
310
311// migrateModel is the bubbletea model for the migrate confirmation.
312type migrateModel struct {
313	spinner  spinner.Model
314	renderer *styles.ConfigRenderer
315	confirm  styles.ConfirmModel
316	state    migrateState
317	uc       *usecase.MigrateConfigUseCase
318	changes  []port.KeyChange
319
320	result   string
321	err      error
322	quitting bool
323}
324
325// migrateResultMsg is sent when the migration completes.
326type migrateResultMsg struct {
327	output *usecase.MigrateConfigOutput
328	err    error
329}
330
331func newMigrateModel(
332	renderer *styles.ConfigRenderer,
333	theme *styles.Theme,
334	uc *usecase.MigrateConfigUseCase,
335	changes []port.KeyChange,
336) migrateModel {
337	s := spinner.New()
338	s.Spinner = spinner.Dot
339	s.Style = lipgloss.NewStyle().Foreground(theme.Accent)
340
341	confirm := styles.NewConfirm(theme, "Apply these changes?")
342
343	return migrateModel{
344		spinner:  s,
345		renderer: renderer,
346		confirm:  confirm,
347		state:    migrateStateConfirm,
348		uc:       uc,
349		changes:  changes,
350	}
351}
352
353func (m migrateModel) Init() tea.Cmd {
354	return m.spinner.Tick
355}
356
357func (m migrateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
358	switch msg := msg.(type) {
359	case tea.KeyMsg:
360		switch msg.String() {
361		case "ctrl+c":
362			m.quitting = true
363			return m, tea.Quit
364		}
365
366	case spinner.TickMsg:
367		var cmd tea.Cmd
368		m.spinner, cmd = m.spinner.Update(msg)
369		return m, cmd
370
371	case migrateResultMsg:
372		m.state = migrateStateDone
373		if msg.err != nil {
374			m.err = msg.err
375			return m, tea.Quit
376		}
377
378		if len(msg.output.AddedKeys) > 0 {
379			m.result = m.renderer.RenderMigrationSuccess(len(msg.output.AddedKeys), msg.output.ConfigFile)
380		}
381		return m, tea.Quit
382	}
383
384	// Handle confirm dialog
385	if m.state == migrateStateConfirm {
386		var cmd tea.Cmd
387		m.confirm, cmd = m.confirm.Update(msg)
388
389		if m.confirm.Done() {
390			if m.confirm.Result() {
391				// User confirmed, run migration
392				return m, m.runMigration()
393			}
394			// User canceled
395			m.quitting = true
396			return m, tea.Quit
397		}
398
399		return m, cmd
400	}
401
402	return m, nil
403}
404
405func (m migrateModel) View() string {
406	if m.quitting {
407		return ""
408	}
409
410	if m.err != nil {
411		return m.renderer.RenderError(m.err)
412	}
413
414	if m.state == migrateStateDone {
415		return m.result
416	}
417
418	// Show confirmation dialog
419	return m.confirm.View()
420}
421
422func (m migrateModel) runMigration() tea.Cmd {
423	return func() tea.Msg {
424		ctx := context.Background()
425		result, err := m.uc.Execute(ctx, usecase.MigrateConfigInput{})
426		return migrateResultMsg{output: result, err: err}
427	}
428}
429
430// runMigrateWithConfirmation runs the migrate with an interactive confirmation dialog.
431func runMigrateWithConfirmation(
432	ctx context.Context,
433	uc *usecase.MigrateConfigUseCase,
434	renderer *styles.ConfigRenderer,
435	theme *styles.Theme,
436	changes []port.KeyChange,
437) error {
438	// Suppress unused ctx warning - kept for consistency with other run functions
439	_ = ctx
440
441	m := newMigrateModel(renderer, theme, uc, changes)
442	p := tea.NewProgram(m)
443
444	_, err := p.Run()
445	if err != nil {
446		return fmt.Errorf("migration failed: %w", err)
447	}
448
449	return nil
450}

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