Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
Hadi
2026-05-12 19:12:29 +02:00
commit e8e64eff12
101 changed files with 10081 additions and 0 deletions
+180
View File
@@ -0,0 +1,180 @@
package plugins
import (
"os"
"strings"
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/paginator"
"charm.land/bubbles/v2/textarea"
"charm.land/bubbles/v2/textinput"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/plugins"
"github.com/anotherhadi/spilltea/internal/style"
)
type Model struct {
manager *plugins.Manager
items []plugins.Info
cursor int
editing bool
filter string
filtered []plugins.Info
listViewport viewport.Model
textarea textarea.Model
filterInput textinput.Model
filtering bool
pager paginator.Model
help help.Model
width int
height int
}
func New(mgr *plugins.Manager) Model {
ta := style.NewTextarea(false)
ta.Placeholder = "plugin configuration..."
ta.Blur()
fi := textinput.New()
fi.Prompt = ""
return Model{
manager: mgr,
listViewport: style.NewViewport(),
textarea: ta,
filterInput: fi,
pager: style.NewPaginator(),
help: style.NewHelp(),
}
}
func (m Model) Init() tea.Cmd { return nil }
func (m Model) IsEditing() bool { return m.editing || m.filtering }
func (m *Model) SetSize(w, h int) {
m.width = w
m.height = h
m.recalcSizes()
}
func (m *Model) recalcSizes() {
if m.width == 0 {
return
}
m.help.SetWidth(m.width - 2)
listH, detailH := style.SplitH(m.height, m.renderStatusBar(), 0.4)
inner := m.width - 2
if inner < 0 {
inner = 0
}
listVH := style.PanelContentH(listH) - 1 // -1 for the pager dots row
if listVH < 0 {
listVH = 0
}
m.listViewport.SetWidth(inner)
m.listViewport.SetHeight(listVH)
m.pager.PerPage = listVH
if m.pager.PerPage < 1 {
m.pager.PerPage = 1
}
m.filterInput.SetWidth(inner - 2)
m.textarea.SetWidth(max(1, inner-2))
m.textarea.SetHeight(max(3, detailH-6))
m.refreshListViewport()
}
// Refresh reloads the plugin list from the manager.
func (m *Model) Refresh() {
if m.manager == nil {
return
}
pl := m.manager.GetPlugins()
m.items = make([]plugins.Info, len(pl))
for i, p := range pl {
m.items[i] = p.Info()
}
m.applyFilter()
}
func (m *Model) applyFilter() {
if m.filter == "" {
m.filtered = m.items
} else {
f := strings.ToLower(m.filter)
filtered := make([]plugins.Info, 0, len(m.items))
for _, p := range m.items {
if strings.Contains(strings.ToLower(p.Name), f) {
filtered = append(filtered, p)
}
}
m.filtered = filtered
}
m.pager.SetTotalPages(len(m.filtered))
if m.cursor >= len(m.filtered) {
m.cursor = max(0, len(m.filtered)-1)
}
m.refreshListViewport()
m.syncTextarea()
}
func (m *Model) selected() (plugins.Info, bool) {
if len(m.filtered) == 0 {
return plugins.Info{}, false
}
return m.filtered[m.cursor], true
}
func (m *Model) syncTextarea() {
if m.editing {
return
}
info, ok := m.selected()
if !ok {
m.textarea.SetValue("")
return
}
m.textarea.SetValue(info.ConfigText)
}
func (m *Model) refreshListViewport() {
if m.pager.PerPage > 0 {
m.pager.Page = m.cursor / m.pager.PerPage
m.pager.SetTotalPages(len(m.filtered))
}
m.listViewport.SetContent(m.renderList())
}
func shortenPath(p string) string {
home := os.Getenv("HOME")
if home != "" && strings.HasPrefix(p, home) {
return "~" + p[len(home):]
}
return p
}
type pluginsKeyMap struct{ editing bool }
func (k pluginsKeyMap) ShortHelp() []key.Binding {
pk := keys.Keys.Plugins
g := keys.Keys.Global
if k.editing {
esc := key.NewBinding(key.WithKeys(g.Escape.Keys()...), key.WithHelp(g.Escape.Help().Key, "save & exit"))
return []key.Binding{esc}
}
return []key.Binding{pk.Toggle, pk.EditConfig, pk.Filter}
}
func (k pluginsKeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{k.ShortHelp()}
}
+130
View File
@@ -0,0 +1,130 @@
package plugins
import (
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/spilltea/internal/keys"
)
// PluginsChangedMsg is sent when the plugin list should be refreshed.
type PluginsChangedMsg struct{}
// RefreshCmd returns a command that triggers a list refresh.
func RefreshCmd() tea.Cmd {
return func() tea.Msg { return PluginsChangedMsg{} }
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.(type) {
case PluginsChangedMsg:
m.Refresh()
return m, nil
}
switch msg := msg.(type) {
case tea.KeyPressMsg:
pk := keys.Keys.Plugins
g := keys.Keys.Global
// Filtering mode: esc clears+closes, enter just closes, rest goes to filterInput.
if m.filtering {
switch {
case key.Matches(msg, g.Escape):
m.filtering = false
m.filter = ""
m.filterInput.SetValue("")
m.filterInput.Blur()
m.applyFilter()
m.recalcSizes()
case msg.String() == "enter":
m.filtering = false
m.filterInput.Blur()
m.recalcSizes()
default:
var cmd tea.Cmd
m.filterInput, cmd = m.filterInput.Update(msg)
m.filter = m.filterInput.Value()
m.applyFilter()
return m, cmd
}
return m, nil
}
// Editing mode: only esc exits, everything else goes to textarea.
if m.editing {
if key.Matches(msg, g.Escape) {
m.editing = false
m.textarea.Blur()
if info, ok := m.selected(); ok && m.manager != nil {
val := m.textarea.Value()
m.manager.SaveConfig(info.Name, val)
// Update cached info.
m.filtered[m.cursor].ConfigText = val
for i := range m.items {
if m.items[i].Name == info.Name {
m.items[i].ConfigText = val
break
}
}
}
return m, nil
}
var cmd tea.Cmd
m.textarea, cmd = m.textarea.Update(msg)
return m, cmd
}
switch {
case key.Matches(msg, g.Escape):
if m.filter != "" {
m.filter = ""
m.filterInput.SetValue("")
m.applyFilter()
}
case key.Matches(msg, pk.Filter):
m.filtering = true
m.filterInput.Focus()
m.recalcSizes()
case key.Matches(msg, g.Up):
if m.cursor > 0 {
m.cursor--
m.refreshListViewport()
m.syncTextarea()
}
case key.Matches(msg, g.Down):
if m.cursor < len(m.filtered)-1 {
m.cursor++
m.refreshListViewport()
m.syncTextarea()
}
case key.Matches(msg, pk.Toggle):
if info, ok := m.selected(); ok && m.manager != nil {
m.manager.TogglePlugin(info.Name)
m.filtered[m.cursor].Enabled = !info.Enabled
for i := range m.items {
if m.items[i].Name == info.Name {
m.items[i].Enabled = !info.Enabled
break
}
}
m.refreshListViewport()
}
case key.Matches(msg, pk.EditConfig):
if _, ok := m.selected(); ok {
m.editing = true
m.textarea.Focus()
}
case key.Matches(msg, g.Help):
m.help.ShowAll = !m.help.ShowAll
m.recalcSizes()
}
}
return m, nil
}
+150
View File
@@ -0,0 +1,150 @@
package plugins
import (
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style"
)
func (m Model) View() tea.View {
if m.width == 0 || m.manager == nil {
return tea.NewView(style.S.Faint.Render("\nno plugins loaded"))
}
listH, detailH := style.SplitH(m.height, m.renderStatusBar(), 0.4)
content := lipgloss.JoinVertical(lipgloss.Left,
m.renderListPanel(m.width, listH),
m.renderDetailPanel(detailH),
m.renderStatusBar(),
)
return tea.NewView(content)
}
func (m *Model) renderListPanel(w, h int) string {
s := style.S
dots := s.Faint.Render(m.pager.View())
inner := lipgloss.JoinVertical(lipgloss.Left,
m.listViewport.View(),
lipgloss.PlaceHorizontal(m.listViewport.Width(), lipgloss.Center, dots),
)
return style.RenderWithTitle(s.PanelFocused, icons.I.Plugin+"Plugins", inner, w, h)
}
func (m *Model) renderDetailPanel(h int) string {
s := style.S
info, ok := m.selected()
if !ok {
return style.RenderWithTitle(s.Panel, "Config", "", m.width, h)
}
var sb strings.Builder
statusSt := lipgloss.NewStyle().Foreground(s.Error)
if info.Enabled {
statusSt = lipgloss.NewStyle().Foreground(s.Success)
}
status := "disabled"
if info.Enabled {
status = "enabled"
}
sb.WriteString(s.Bold.Render(info.Name) + " " + statusSt.Render(status) + "\n")
sb.WriteString(s.Faint.Render(shortenPath(info.FilePath)) + "\n\n")
if m.editing {
escKey := keys.Keys.Global.Escape.Help().Key
sb.WriteString(s.Faint.Render("editing config (" + escKey + " to save):"))
} else {
editKey := keys.Keys.Plugins.EditConfig.Help().Key
sb.WriteString(s.Faint.Render("config (" + editKey + " to edit):"))
}
inner := lipgloss.JoinVertical(lipgloss.Left,
lipgloss.NewStyle().Padding(0, 1).Render(sb.String()),
lipgloss.NewStyle().Padding(0, 1).Render(m.textarea.View()),
)
return style.RenderWithTitle(s.Panel, "Detail", inner, m.width, h)
}
func (m *Model) renderStatusBar() string {
s := style.S
pad := lipgloss.NewStyle().Padding(0, 1)
filterKey := keys.Keys.Plugins.Filter.Help().Key
if m.filtering {
return pad.Render(s.Faint.Render(filterKey) + " " + m.filterInput.View())
}
if m.filter != "" {
escKey := keys.Keys.Global.Escape.Help().Key
accent := lipgloss.NewStyle().Foreground(s.Primary)
filterLine := pad.Render(accent.Render(filterKey) + " " + s.Bold.Render(m.filter) + s.Faint.Render(" "+escKey+" to clear"))
return lipgloss.JoinVertical(lipgloss.Left, filterLine, pad.Render(m.help.View(pluginsKeyMap{editing: m.editing})))
}
return pad.Render(m.help.View(pluginsKeyMap{editing: m.editing}))
}
func (m *Model) renderList() string {
s := style.S
if len(m.filtered) == 0 {
msg := " (ง •̀_•́)ง\nno plugins"
if m.filter != "" {
msg = " = _ =\nno results"
}
return lipgloss.Place(
m.listViewport.Width(), m.listViewport.Height(),
lipgloss.Center, lipgloss.Center,
s.Faint.Render(msg),
)
}
start, end := m.pager.GetSliceBounds(len(m.filtered))
if start < 0 {
start = 0
}
if end < start {
end = start
}
var sb strings.Builder
for i, p := range m.filtered[start:end] {
globalIdx := start + i
selected := globalIdx == m.cursor
enabledSt := lipgloss.NewStyle().Foreground(s.Error)
enabledStr := "off"
if p.Enabled {
enabledSt = lipgloss.NewStyle().Foreground(s.Success)
enabledStr = "on "
}
w := m.listViewport.Width()
const fixedW = 2 + 3 + 1
nameW := w - fixedW
if nameW < 0 {
nameW = 0
}
var line string
if selected {
bg := lipgloss.NewStyle().Background(s.Selection)
line = lipgloss.JoinHorizontal(lipgloss.Top,
bg.Bold(true).Foreground(s.Primary).Width(2).Render(">"),
enabledSt.Background(s.Selection).Width(3).Render(enabledStr),
bg.Width(1).Render(""),
bg.Bold(true).Width(nameW).Render(p.Name),
)
} else {
line = lipgloss.JoinHorizontal(lipgloss.Top,
" ",
enabledSt.Width(3).Render(enabledStr),
" ",
s.Bold.Render(p.Name),
)
}
sb.WriteString(line + "\n")
}
return sb.String()
}