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}