From fcbbeccaa80c7e120933682b330272a1490c1cf5 Mon Sep 17 00:00:00 2001 From: hachem Date: Sat, 29 Aug 2026 18:24:42 +0200 Subject: refactor: unify mods command with the search --- internal/cli/mods.go | 318 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 318 insertions(+) create mode 100644 internal/cli/mods.go (limited to 'internal/cli/mods.go') diff --git a/internal/cli/mods.go b/internal/cli/mods.go new file mode 100644 index 0000000..b5ff93e --- /dev/null +++ b/internal/cli/mods.go @@ -0,0 +1,318 @@ +package cli + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" + + "github.com/hachem-wtf/benson/internal/config" + "github.com/hachem-wtf/benson/internal/modrinth" +) + +const ( + stageMenu = iota + stageQuery + stageResults + stageVersion + stageRemove +) + +type modManagerMsg struct { + kind string + hits []modrinth.SearchHit + versions []modrinth.Version + query string + err error +} + +func runMods(m *model) tea.Cmd { + m.openMenu() + return tea.Batch(m.form.Init(), sizeCmd(m.width, m.height)) +} + +func (m *model) updatePicker(msg tea.Msg) (tea.Model, tea.Cmd) { + if size, ok := msg.(tea.WindowSizeMsg); ok { + m.width, m.height = size.Width, size.Height + } + + form, cmd := m.form.Update(msg) + if f, ok := form.(*huh.Form); ok { + m.form = f + } + + switch m.form.State { + case huh.StateAborted: + if m.pickerStage == stageMenu { + m.form = nil + return m, nil + } + return m, m.reopenMenu() + case huh.StateCompleted: + return m, m.advance() + } + return m, cmd +} + +func (m *model) advance() tea.Cmd { + switch m.pickerStage { + case stageMenu: + switch m.pickerAction { + case "add": + m.openQuery() + return tea.Batch(m.form.Init(), sizeCmd(m.width, m.height)) + case "remove": + m.openRemove() + return tea.Batch(m.form.Init(), sizeCmd(m.width, m.height)) + default: + m.form = nil + return tea.Println(renderModList(m.cfg)) + } + + case stageQuery: + query := strings.TrimSpace(m.pickerQuery) + m.form = nil + if query == "" { + return m.reopenMenu() + } + return m.searchCmd(query) + + case stageResults: + slug := m.pickerSlug + m.form = nil + return m.versionsCmd(slug) + + case stageVersion: + m.cfg.ModrinthMods = upsertMod(m.cfg.ModrinthMods, config.Mod{Slug: m.pickerSlug, Version: m.pickerVersion}) + return m.saveThenMenu(successStyle.Render("Added ") + m.pickerSlug + mutedStyle.Render(" ("+versionTag(m.pickerVersion)+")")) + + case stageRemove: + m.cfg.ModrinthMods = removeMod(m.cfg.ModrinthMods, m.pickerSlug) + return m.saveThenMenu(successStyle.Render("Removed ") + m.pickerSlug) + } + return nil +} + +func (m *model) searchCmd(query string) tea.Cmd { + m.busy = true + mc := m.cfg.MinecraftVersion + return tea.Batch(m.spin.Tick, func() tea.Msg { + hits, err := modrinth.NewClient().Search(query, mc, "fabric") + return modManagerMsg{kind: "search", hits: hits, query: query, err: err} + }) +} + +func (m *model) versionsCmd(slug string) tea.Cmd { + m.busy = true + return tea.Batch(m.spin.Tick, func() tea.Msg { + versions, err := modrinth.NewClient().GetVersions(slug) + return modManagerMsg{kind: "versions", versions: versions, err: err} + }) +} + +func (m *model) handleModManager(msg modManagerMsg) tea.Cmd { + if msg.err != nil { + return tea.Sequence(tea.Println(errorStyle.Render("Error: "+msg.err.Error())), m.reopenMenu()) + } + + switch msg.kind { + case "search": + if len(msg.hits) == 0 { + return tea.Sequence(tea.Println(mutedStyle.Render("No results for “"+msg.query+"”.")), m.reopenMenu()) + } + m.openResults(msg.hits) + case "versions": + m.openVersion(msg.versions) + } + return tea.Batch(m.form.Init(), sizeCmd(m.width, m.height)) +} + +func (m *model) saveThenMenu(notice string) tea.Cmd { + if err := config.Save(m.cfg); err != nil { + return tea.Sequence(tea.Println(errorStyle.Render("Failed to save config: "+err.Error())), m.reopenMenu()) + } + return tea.Sequence(tea.Println(notice), m.reopenMenu()) +} + +func (m *model) reopenMenu() tea.Cmd { + m.openMenu() + return tea.Batch(m.form.Init(), sizeCmd(m.width, m.height)) +} + +func (m *model) openMenu() { + m.pickerStage = stageMenu + m.pickerAction = "add" + + options := []huh.Option[string]{huh.NewOption("Search & add a mod", "add")} + if len(m.cfg.ModrinthMods) > 0 { + options = append(options, huh.NewOption("Remove a mod", "remove")) + } + options = append(options, huh.NewOption("Done", "done")) + + m.form = huh.NewForm(huh.NewGroup( + huh.NewSelect[string](). + Title(fmt.Sprintf("Mods · %d configured", len(m.cfg.ModrinthMods))). + Description(modsSummary(m.cfg.ModrinthMods)). + Options(options...). + Value(&m.pickerAction), + )).WithTheme(pickerTheme()) +} + +func (m *model) openQuery() { + m.pickerStage = stageQuery + m.pickerQuery = "" + m.form = huh.NewForm(huh.NewGroup( + huh.NewInput(). + Title("Search Modrinth"). + Description("Mod name or keyword · esc to go back"). + Value(&m.pickerQuery), + )).WithTheme(pickerTheme()) +} + +func (m *model) openResults(hits []modrinth.SearchHit) { + m.pickerStage = stageResults + m.pickerSlug = hits[0].Slug + + options := make([]huh.Option[string], len(hits)) + for i, h := range hits { + options[i] = huh.NewOption(h.Title+" ("+h.Slug+")", h.Slug) + } + + m.form = huh.NewForm(huh.NewGroup( + huh.NewSelect[string](). + Title("Add a mod"). + Description("Pick a search result · esc to go back"). + Options(options...). + Height(selectHeight(len(options))). + Value(&m.pickerSlug), + )).WithTheme(pickerTheme()) +} + +func (m *model) openVersion(versions []modrinth.Version) { + m.pickerStage = stageVersion + m.pickerVersion = "" + + options := []huh.Option[string]{huh.NewOption("Latest compatible · recommended", "")} + for _, v := range versions { + if containsStr(v.Loaders, "fabric") && containsStr(v.GameVersions, m.cfg.MinecraftVersion) { + options = append(options, huh.NewOption(v.VersionNumber, v.VersionNumber)) + } + } + + m.form = huh.NewForm(huh.NewGroup( + huh.NewSelect[string](). + Title("Version for " + m.pickerSlug). + Description("Keep it on latest, or pin one"). + Options(options...). + Height(selectHeight(len(options))). + Value(&m.pickerVersion), + )).WithTheme(pickerTheme()) +} + +func (m *model) openRemove() { + m.pickerStage = stageRemove + m.pickerSlug = m.cfg.ModrinthMods[0].Slug + + options := make([]huh.Option[string], len(m.cfg.ModrinthMods)) + for i, mod := range m.cfg.ModrinthMods { + options[i] = huh.NewOption(modLabel(mod), mod.Slug) + } + + m.form = huh.NewForm(huh.NewGroup( + huh.NewSelect[string](). + Title("Remove which mod?"). + Description("esc to go back"). + Options(options...). + Height(selectHeight(len(options))). + Value(&m.pickerSlug), + )).WithTheme(pickerTheme()) +} + +func renderModList(cfg *config.Config) string { + rows := []string{headingStyle.Render(fmt.Sprintf("Mods for MC %s", cfg.MinecraftVersion)), ""} + if len(cfg.ModrinthMods) == 0 { + rows = append(rows, mutedStyle.Render(" (none configured)")) + } + for _, mod := range cfg.ModrinthMods { + tag := mutedStyle.Render(" latest") + if mod.Pinned() { + tag = warnStyle.Render(" pinned " + mod.Version) + } + rows = append(rows, " "+bulletStyle.Render("◆ ")+mod.Slug+tag) + } + rows = append(rows, "", mutedStyle.Render(fmt.Sprintf("Total: %d mods", len(cfg.ModrinthMods)))) + return lipgloss.JoinVertical(lipgloss.Left, rows...) +} + +func modsSummary(mods []config.Mod) string { + if len(mods) == 0 { + return "No mods yet — search Modrinth to add some." + } + var b strings.Builder + for _, mod := range mods { + fmt.Fprintf(&b, "• %s\n", modLabel(mod)) + } + return strings.TrimRight(b.String(), "\n") +} + +func modLabel(mod config.Mod) string { + if mod.Pinned() { + return mod.Slug + " (" + mod.Version + ")" + } + return mod.Slug + " (latest)" +} + +func versionTag(version string) string { + if version == "" { + return "latest" + } + return "pinned " + version +} + +func upsertMod(mods []config.Mod, mod config.Mod) []config.Mod { + for i := range mods { + if mods[i].Slug == mod.Slug { + mods[i] = mod + return mods + } + } + return append(mods, mod) +} + +func removeMod(mods []config.Mod, slug string) []config.Mod { + kept := make([]config.Mod, 0, len(mods)) + for _, mod := range mods { + if mod.Slug != slug { + kept = append(kept, mod) + } + } + return kept +} + +func selectHeight(n int) int { + return min(10, n) + 2 +} + +func containsStr(list []string, s string) bool { + for _, v := range list { + if v == s { + return true + } + } + return false +} + +func pickerTheme() *huh.Theme { + theme := huh.ThemeCharm() + theme.Focused.Title = theme.Focused.Title.Foreground(colAccent).Bold(true) + theme.Focused.Base = theme.Focused.Base.BorderForeground(colPrimary) + return theme +} + +func sizeCmd(w, h int) tea.Cmd { + return func() tea.Msg { + return tea.WindowSizeMsg{Width: w, Height: h} + } +} -- cgit v1.3