marketplace.go

  1package tui
  2
  3import (
  4	"fmt"
  5	"os"
  6	"path/filepath"
  7	"strings"
  8
  9	tea "charm.land/bubbletea/v2"
 10	"charm.land/lipgloss/v2"
 11	"github.com/floatpane/matcha/config"
 12	"github.com/floatpane/matcha/plugins"
 13	"github.com/floatpane/matcha/theme"
 14)
 15
 16var (
 17	mpTitleStyle = lipgloss.NewStyle().
 18			Foreground(lipgloss.Color("#FFFDF5")).
 19			Background(lipgloss.Color("#25A065")).
 20			Padding(0, 1)
 21
 22	mpItemNameStyle = lipgloss.NewStyle().
 23			Foreground(lipgloss.Color("42")).
 24			Bold(true)
 25
 26	mpItemDescStyle = lipgloss.NewStyle().
 27			Foreground(lipgloss.Color("245"))
 28
 29	mpInstalledStyle = lipgloss.NewStyle().
 30				Foreground(lipgloss.Color("35"))
 31
 32	mpSelectedStyle = lipgloss.NewStyle().
 33			Foreground(lipgloss.Color("42")).
 34			Bold(true)
 35
 36	mpCursorStyle = lipgloss.NewStyle().
 37			Foreground(lipgloss.Color("42"))
 38
 39	mpStatusStyle = lipgloss.NewStyle().
 40			Foreground(lipgloss.Color("214"))
 41)
 42
 43type marketplaceState int
 44
 45const (
 46	marketplaceLoading marketplaceState = iota
 47	marketplaceReady
 48	marketplaceError
 49)
 50
 51// RegistryFetchedMsg signals that the plugin registry was fetched.
 52type RegistryFetchedMsg struct {
 53	Entries []plugins.PluginEntry
 54	Err     error
 55}
 56
 57// PluginInstalledMsg signals that a plugin was installed from the marketplace.
 58type PluginInstalledMsg struct {
 59	Name string
 60	Err  error
 61}
 62
 63type Marketplace struct {
 64	entries    []plugins.PluginEntry
 65	installed  map[string]bool
 66	cursor     int
 67	offset     int // scroll offset
 68	width      int
 69	height     int
 70	state      marketplaceState
 71	errMsg     string
 72	status     string // transient status message
 73	standalone bool   // true when launched via `matcha marketplace` (not from main menu)
 74}
 75
 76func NewMarketplace(standalone bool) Marketplace {
 77	return Marketplace{
 78		installed:  installedPlugins(),
 79		standalone: standalone,
 80	}
 81}
 82
 83func (m Marketplace) Init() tea.Cmd {
 84	return fetchRegistry
 85}
 86
 87func fetchRegistry() tea.Msg {
 88	entries, err := plugins.FetchRegistry()
 89	return RegistryFetchedMsg{Entries: entries, Err: err}
 90}
 91
 92func (m Marketplace) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 93	switch msg := msg.(type) {
 94	case tea.WindowSizeMsg:
 95		m.width = msg.Width
 96		m.height = msg.Height
 97		return m, nil
 98
 99	case RegistryFetchedMsg:
100		if msg.Err != nil {
101			m.state = marketplaceError
102			m.errMsg = msg.Err.Error()
103			return m, nil
104		}
105		m.entries = msg.Entries
106		m.state = marketplaceReady
107		return m, nil
108
109	case PluginInstalledMsg:
110		if msg.Err != nil {
111			m.status = fmt.Sprintf("Failed to install %s: %v", msg.Name, msg.Err)
112		} else {
113			m.status = fmt.Sprintf("Installed %s", msg.Name)
114			m.installed[msg.Name] = true
115		}
116		return m, nil
117
118	case tea.KeyPressMsg:
119		kb := config.Keybinds
120		if m.state != marketplaceReady {
121			if msg.String() == "q" || msg.String() == kb.Global.Cancel || msg.String() == kb.Global.Quit {
122				if m.standalone {
123					return m, tea.Quit
124				}
125				return m, func() tea.Msg { return GoToChoiceMenuMsg{} }
126			}
127			return m, nil
128		}
129
130		switch msg.String() {
131		case "q", kb.Global.Cancel:
132			if m.standalone {
133				return m, tea.Quit
134			}
135			return m, func() tea.Msg { return GoToChoiceMenuMsg{} }
136		case kb.Global.Quit:
137			return m, tea.Quit
138		case "up", kb.Global.NavUp:
139			if m.cursor > 0 {
140				m.cursor--
141				if m.cursor < m.offset {
142					m.offset = m.cursor
143				}
144			}
145		case keyDown, kb.Global.NavDown:
146			if m.cursor < len(m.entries)-1 {
147				m.cursor++
148				visible := m.visibleRows()
149				if m.cursor >= m.offset+visible {
150					m.offset = m.cursor - visible + 1
151				}
152			}
153		case keyEnter:
154			if m.cursor < len(m.entries) {
155				entry := m.entries[m.cursor]
156				if m.installed[entry.Name] {
157					m.status = fmt.Sprintf("%s is already installed", entry.Name)
158					return m, nil
159				}
160				m.status = fmt.Sprintf("Installing %s...", entry.Name)
161				return m, installPlugin(entry)
162			}
163		}
164	}
165	return m, nil
166}
167
168func (m Marketplace) visibleRows() int {
169	// Each entry takes 2 lines (name + description), plus header/footer
170	available := m.height - 8 // header + footer + padding
171	if available < 1 {
172		return 1
173	}
174	return available / 2
175}
176
177func installPlugin(entry plugins.PluginEntry) tea.Cmd {
178	return func() tea.Msg {
179		data, err := plugins.FetchPlugin(entry)
180		if err != nil {
181			return PluginInstalledMsg{Name: entry.Name, Err: err}
182		}
183
184		home, err := os.UserHomeDir()
185		if err != nil {
186			return PluginInstalledMsg{Name: entry.Name, Err: err}
187		}
188
189		dir := filepath.Join(home, ".config", "matcha", "plugins")
190		if err := os.MkdirAll(dir, 0750); err != nil {
191			return PluginInstalledMsg{Name: entry.Name, Err: err}
192		}
193
194		dest := filepath.Join(dir, entry.File)
195		if err := os.WriteFile(dest, data, 0644); err != nil {
196			return PluginInstalledMsg{Name: entry.Name, Err: err}
197		}
198
199		return PluginInstalledMsg{Name: entry.Name}
200	}
201}
202
203func installedPlugins() map[string]bool {
204	installed := make(map[string]bool)
205	home, err := os.UserHomeDir()
206	if err != nil {
207		return installed
208	}
209	dir := filepath.Join(home, ".config", "matcha", "plugins")
210	entries, err := os.ReadDir(dir)
211	if err != nil {
212		return installed
213	}
214	for _, e := range entries {
215		if !e.IsDir() && strings.HasSuffix(e.Name(), ".lua") {
216			name := strings.TrimSuffix(e.Name(), ".lua")
217			installed[name] = true
218		}
219	}
220	return installed
221}
222
223func (m Marketplace) View() tea.View {
224	var b strings.Builder
225
226	accentStyle := lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent)
227	b.WriteString(accentStyle.Render(choiceLogo))
228	b.WriteString("\n")
229	b.WriteString(mpTitleStyle.Render(" Plugin Marketplace "))
230	b.WriteString("\n\n")
231
232	switch m.state {
233	case marketplaceLoading:
234		b.WriteString("  Fetching plugins...\n")
235	case marketplaceError:
236		errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
237		b.WriteString(errStyle.Render(fmt.Sprintf("  Error: %s", m.errMsg)))
238		b.WriteString("\n")
239	case marketplaceReady:
240		visible := m.visibleRows()
241		end := m.offset + visible
242		if end > len(m.entries) {
243			end = len(m.entries)
244		}
245
246		for i := m.offset; i < end; i++ {
247			entry := m.entries[i]
248			cursor := "  "
249			nameStyle := mpItemNameStyle
250			if i == m.cursor {
251				cursor = mpCursorStyle.Render("> ")
252				nameStyle = mpSelectedStyle
253			}
254
255			name := nameStyle.Render(entry.Title)
256			if m.installed[entry.Name] {
257				name += " " + mpInstalledStyle.Render("[installed]")
258			}
259
260			fmt.Fprintf(&b, "%s%s\n", cursor, name)
261			fmt.Fprintf(&b, "    %s\n", mpItemDescStyle.Render(entry.Description))
262		}
263
264		if len(m.entries) > visible {
265			fmt.Fprintf(&b, "\n  %d/%d plugins", m.cursor+1, len(m.entries))
266		}
267	}
268
269	if m.status != "" {
270		b.WriteString("\n")
271		b.WriteString(mpStatusStyle.Render("  " + m.status))
272	}
273
274	mainContent := b.String()
275	help := helpStyle.Render("↑/↓ navigate • enter install • q back")
276
277	if m.height > 0 {
278		currentHeight := lipgloss.Height(DocStyle.Render(mainContent + "\n" + help))
279		gap := m.height - currentHeight
280		if gap > 0 {
281			mainContent += strings.Repeat("\n", gap)
282		}
283	} else {
284		mainContent += "\n\n"
285	}
286
287	return tea.NewView(DocStyle.Render(mainContent + "\n" + help))
288}