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}