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