ui.go

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: AGPL-3.0-or-later
  4
  5package init
  6
  7import (
  8	"errors"
  9	"fmt"
 10	"os"
 11	"regexp"
 12	"strconv"
 13	"strings"
 14
 15	"github.com/charmbracelet/huh"
 16	"github.com/spf13/cobra"
 17
 18	"git.secluded.site/go-lunatask"
 19	"git.secluded.site/lune/internal/config"
 20	"git.secluded.site/lune/internal/ui"
 21)
 22
 23const (
 24	choiceBack   = "back"
 25	choiceNext   = "next"
 26	choiceAdd    = "add"
 27	choiceDone   = "done"
 28	actionEdit   = "edit"
 29	actionDelete = "delete"
 30	actionGoals  = "goals"
 31	colorAuto    = "auto"
 32)
 33
 34var (
 35	errNameRequired  = errors.New("name is required")
 36	errKeyRequired   = errors.New("key is required")
 37	errTokenRequired = errors.New("access token is required")
 38	errKeyFormat     = errors.New("key must be lowercase letters, numbers, and hyphens (e.g. 'work' or 'q1-goals')")
 39	errKeyDuplicate  = errors.New("this key is already in use")
 40	errRefRequired   = errors.New("reference is required")
 41	errRefFormat     = errors.New("expected UUID or lunatask:// deep link")
 42	errIndexOutRange = errors.New("index out of range")
 43	errBack          = errors.New("user went back")
 44)
 45
 46func configureUIPrefs(cfg *config.Config) error {
 47	color := cfg.UI.Color
 48	if color == "" {
 49		color = colorAuto
 50	}
 51
 52	err := huh.NewSelect[string]().
 53		Title("Color output").
 54		Description("When should lune use colored output?").
 55		Options(
 56			huh.NewOption("Auto (detect terminal capability)", colorAuto),
 57			huh.NewOption("Always", "always"),
 58			huh.NewOption("Never", "never"),
 59		).
 60		Value(&color).
 61		Run()
 62	if err != nil {
 63		if errors.Is(err, huh.ErrUserAborted) {
 64			return errQuit
 65		}
 66
 67		return err
 68	}
 69
 70	cfg.UI.Color = color
 71
 72	return nil
 73}
 74
 75func resetConfig(cmd *cobra.Command, cfg *config.Config) error {
 76	path, err := config.Path()
 77	if err != nil {
 78		return err
 79	}
 80
 81	var confirm bool
 82
 83	err = huh.NewConfirm().
 84		Title("Reset all configuration?").
 85		Description(fmt.Sprintf("This will delete %s and restart the setup wizard.", path)).
 86		Affirmative("Reset").
 87		Negative("Cancel").
 88		Value(&confirm).
 89		Run()
 90	if err != nil {
 91		if errors.Is(err, huh.ErrUserAborted) {
 92			return errQuit
 93		}
 94
 95		return err
 96	}
 97
 98	if confirm {
 99		if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
100			return fmt.Errorf("removing config: %w", err)
101		}
102
103		fmt.Fprintln(cmd.OutOrStdout(), ui.Success.Render("Configuration reset."))
104		fmt.Fprintln(cmd.OutOrStdout())
105
106		*cfg = config.Config{}
107
108		return errReset
109	}
110
111	return nil
112}
113
114func runListSelect(title, description string, options []huh.Option[string]) (string, error) {
115	var choice string
116
117	selector := huh.NewSelect[string]().
118		Title(title).
119		Options(options...).
120		Value(&choice)
121
122	if description != "" {
123		selector = selector.Description(description)
124	}
125
126	if err := selector.Run(); err != nil {
127		if errors.Is(err, huh.ErrUserAborted) {
128			return "", errQuit
129		}
130
131		return "", err
132	}
133
134	return choice, nil
135}
136
137func parseEditIndex(choice string) (int, bool) {
138	if !strings.HasPrefix(choice, "edit:") {
139		return 0, false
140	}
141
142	idx, err := strconv.Atoi(strings.TrimPrefix(choice, "edit:"))
143	if err != nil {
144		return 0, false
145	}
146
147	return idx, true
148}
149
150var keyPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
151
152func validateKeyFormat(input string) error {
153	if input == "" {
154		return errKeyRequired
155	}
156
157	if !keyPattern.MatchString(input) {
158		return errKeyFormat
159	}
160
161	return nil
162}
163
164func validateReference(input string, _ bool) (string, error) {
165	if input == "" {
166		return "", errRefRequired
167	}
168
169	_, id, err := lunatask.ParseDeepLink(input)
170	if err != nil {
171		return "", errRefFormat
172	}
173
174	return id, nil
175}
176
177type itemFormConfig struct {
178	itemType         string
179	namePlaceholder  string
180	keyPlaceholder   string
181	keyValidator     func(string) error
182	supportsDeepLink bool
183}
184
185func titleCase(s string) string {
186	if s == "" {
187		return s
188	}
189
190	return strings.ToUpper(s[:1]) + s[1:]
191}
192
193// itemAction represents the result of an action selection dialog.
194type itemAction int
195
196const (
197	itemActionNone itemAction = iota
198	itemActionEdit
199	itemActionDelete
200	itemActionGoals
201)
202
203// runActionSelect shows an action selection dialog for an item and returns the chosen action.
204func runActionSelect(title string, includeGoals bool) (itemAction, error) {
205	var action string
206
207	options := []huh.Option[string]{
208		huh.NewOption("Edit", actionEdit),
209	}
210
211	if includeGoals {
212		options = append(options, huh.NewOption("Manage goals", actionGoals))
213	}
214
215	options = append(options,
216		huh.NewOption("Delete", actionDelete),
217		huh.NewOption("Back", choiceBack),
218	)
219
220	err := huh.NewSelect[string]().
221		Title(title).
222		Options(options...).
223		Value(&action).
224		Run()
225	if err != nil {
226		if errors.Is(err, huh.ErrUserAborted) {
227			return itemActionNone, errQuit
228		}
229
230		return itemActionNone, err
231	}
232
233	switch action {
234	case actionEdit:
235		return itemActionEdit, nil
236	case actionDelete:
237		return itemActionDelete, nil
238	case actionGoals:
239		return itemActionGoals, nil
240	default:
241		return itemActionNone, nil
242	}
243}
244
245func runItemForm(name, key, itemID *string, cfg itemFormConfig) error {
246	var save bool
247
248	requireNonEmpty := false
249
250	for {
251		form := buildItemFormGroup(name, key, itemID, cfg, &requireNonEmpty, &save)
252
253		if err := form.Run(); err != nil {
254			if errors.Is(err, huh.ErrUserAborted) {
255				return errBack
256			}
257
258			return err
259		}
260
261		if !save {
262			return errBack
263		}
264
265		// If any required field is empty, re-run with strict validation
266		if *name == "" || *key == "" || *itemID == "" {
267			requireNonEmpty = true
268
269			continue
270		}
271
272		// Normalise reference to UUID
273		parsedID, _ := validateReference(*itemID, cfg.supportsDeepLink)
274		*itemID = parsedID
275
276		return nil
277	}
278}
279
280func buildItemFormGroup(
281	name, key, itemID *string,
282	cfg itemFormConfig,
283	requireNonEmpty *bool,
284	save *bool,
285) *huh.Form {
286	refDescription := "Settings → 'Copy " + titleCase(cfg.itemType) + " ID' (bottom left)."
287	if cfg.supportsDeepLink {
288		refDescription = "Paste UUID or lunatask:// deep link."
289	}
290
291	return huh.NewForm(
292		huh.NewGroup(
293			huh.NewInput().
294				Title("Name").
295				Description("Display name for this "+cfg.itemType+".").
296				Placeholder(cfg.namePlaceholder).
297				Value(name).
298				Validate(makeNameValidator(requireNonEmpty)),
299			huh.NewInput().
300				Title("Key").
301				Description("Short alias for CLI use (lowercase, no spaces).").
302				Placeholder(cfg.keyPlaceholder).
303				Value(key).
304				Validate(makeKeyValidator(requireNonEmpty, cfg.keyValidator)),
305			huh.NewInput().
306				Title("Reference").
307				Description(refDescription).
308				Placeholder("xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx").
309				Value(itemID).
310				Validate(makeRefValidator(requireNonEmpty, cfg.supportsDeepLink)),
311			huh.NewConfirm().
312				Title("Save this "+cfg.itemType+"?").
313				Affirmative("Save").
314				Negative("Cancel").
315				Value(save),
316		),
317	)
318}
319
320func makeNameValidator(requireNonEmpty *bool) func(string) error {
321	return func(input string) error {
322		if *requireNonEmpty && input == "" {
323			return errNameRequired
324		}
325
326		return nil
327	}
328}
329
330func makeKeyValidator(requireNonEmpty *bool, formatValidator func(string) error) func(string) error {
331	return func(input string) error {
332		if input == "" {
333			if *requireNonEmpty {
334				return errKeyRequired
335			}
336
337			return nil
338		}
339
340		return formatValidator(input)
341	}
342}
343
344func makeRefValidator(requireNonEmpty *bool, supportsDeepLink bool) func(string) error {
345	return func(input string) error {
346		if input == "" {
347			if *requireNonEmpty {
348				return errRefRequired
349			}
350
351			return nil
352		}
353
354		_, err := validateReference(input, supportsDeepLink)
355
356		return err
357	}
358}