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/lune/internal/config"
 19	"git.secluded.site/lune/internal/deeplink"
 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, supportsDeepLink bool) (string, error) {
165	if input == "" {
166		return "", errRefRequired
167	}
168
169	if supportsDeepLink {
170		id, err := deeplink.ParseID(input)
171		if err != nil {
172			return "", errRefFormat
173		}
174
175		return id, nil
176	}
177
178	// Habits don't support deep links, only raw UUIDs
179	id, err := deeplink.ParseID(input)
180	if err != nil {
181		return "", errRefFormat
182	}
183
184	return id, nil
185}
186
187type itemFormConfig struct {
188	itemType         string
189	namePlaceholder  string
190	keyPlaceholder   string
191	keyValidator     func(string) error
192	supportsDeepLink bool
193}
194
195func titleCase(s string) string {
196	if s == "" {
197		return s
198	}
199
200	return strings.ToUpper(s[:1]) + s[1:]
201}
202
203// itemAction represents the result of an action selection dialog.
204type itemAction int
205
206const (
207	itemActionNone itemAction = iota
208	itemActionEdit
209	itemActionDelete
210	itemActionGoals
211)
212
213// runActionSelect shows an action selection dialog for an item and returns the chosen action.
214func runActionSelect(title string, includeGoals bool) (itemAction, error) {
215	var action string
216
217	options := []huh.Option[string]{
218		huh.NewOption("Edit", actionEdit),
219	}
220
221	if includeGoals {
222		options = append(options, huh.NewOption("Manage goals", actionGoals))
223	}
224
225	options = append(options,
226		huh.NewOption("Delete", actionDelete),
227		huh.NewOption("Back", choiceBack),
228	)
229
230	err := huh.NewSelect[string]().
231		Title(title).
232		Options(options...).
233		Value(&action).
234		Run()
235	if err != nil {
236		if errors.Is(err, huh.ErrUserAborted) {
237			return itemActionNone, errQuit
238		}
239
240		return itemActionNone, err
241	}
242
243	switch action {
244	case actionEdit:
245		return itemActionEdit, nil
246	case actionDelete:
247		return itemActionDelete, nil
248	case actionGoals:
249		return itemActionGoals, nil
250	default:
251		return itemActionNone, nil
252	}
253}
254
255func runItemForm(name, key, itemID *string, cfg itemFormConfig) error {
256	var save bool
257
258	requireNonEmpty := false
259
260	for {
261		form := buildItemFormGroup(name, key, itemID, cfg, &requireNonEmpty, &save)
262
263		if err := form.Run(); err != nil {
264			if errors.Is(err, huh.ErrUserAborted) {
265				return errBack
266			}
267
268			return err
269		}
270
271		if !save {
272			return errBack
273		}
274
275		// If any required field is empty, re-run with strict validation
276		if *name == "" || *key == "" || *itemID == "" {
277			requireNonEmpty = true
278
279			continue
280		}
281
282		// Normalise reference to UUID
283		parsedID, _ := validateReference(*itemID, cfg.supportsDeepLink)
284		*itemID = parsedID
285
286		return nil
287	}
288}
289
290func buildItemFormGroup(
291	name, key, itemID *string,
292	cfg itemFormConfig,
293	requireNonEmpty *bool,
294	save *bool,
295) *huh.Form {
296	refDescription := "Settings → 'Copy " + titleCase(cfg.itemType) + " ID' (bottom left)."
297	if cfg.supportsDeepLink {
298		refDescription = "Paste UUID or lunatask:// deep link."
299	}
300
301	return huh.NewForm(
302		huh.NewGroup(
303			huh.NewInput().
304				Title("Name").
305				Description("Display name for this "+cfg.itemType+".").
306				Placeholder(cfg.namePlaceholder).
307				Value(name).
308				Validate(makeNameValidator(requireNonEmpty)),
309			huh.NewInput().
310				Title("Key").
311				Description("Short alias for CLI use (lowercase, no spaces).").
312				Placeholder(cfg.keyPlaceholder).
313				Value(key).
314				Validate(makeKeyValidator(requireNonEmpty, cfg.keyValidator)),
315			huh.NewInput().
316				Title("Reference").
317				Description(refDescription).
318				Placeholder("xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx").
319				Value(itemID).
320				Validate(makeRefValidator(requireNonEmpty, cfg.supportsDeepLink)),
321			huh.NewConfirm().
322				Title("Save this "+cfg.itemType+"?").
323				Affirmative("Save").
324				Negative("Cancel").
325				Value(save),
326		),
327	)
328}
329
330func makeNameValidator(requireNonEmpty *bool) func(string) error {
331	return func(input string) error {
332		if *requireNonEmpty && input == "" {
333			return errNameRequired
334		}
335
336		return nil
337	}
338}
339
340func makeKeyValidator(requireNonEmpty *bool, formatValidator func(string) error) func(string) error {
341	return func(input string) error {
342		if input == "" {
343			if *requireNonEmpty {
344				return errKeyRequired
345			}
346
347			return nil
348		}
349
350		return formatValidator(input)
351	}
352}
353
354func makeRefValidator(requireNonEmpty *bool, supportsDeepLink bool) func(string) error {
355	return func(input string) error {
356		if input == "" {
357			if *requireNonEmpty {
358				return errRefRequired
359			}
360
361			return nil
362		}
363
364		_, err := validateReference(input, supportsDeepLink)
365
366		return err
367	}
368}