Change plugins behavior

Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
Hadi
2026-05-13 16:52:12 +02:00
parent dbea0ab0f2
commit 4eb9dd53f5
23 changed files with 740 additions and 241 deletions
+10 -1
View File
@@ -44,11 +44,20 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case plugins.PluginNotifMsg:
cmd := plugins.WaitForNotif(m.pluginManager)
kind := notificationsUI.KindInfo
switch msg.Kind {
case "success":
kind = notificationsUI.KindSuccess
case "warning":
kind = notificationsUI.KindWarning
case "error":
kind = notificationsUI.KindError
}
notifCmd := func() tea.Msg {
return notificationsUI.NotificationMsg{
Title: msg.Title,
Body: msg.Body,
Kind: notificationsUI.KindInfo,
Kind: kind,
}
}
return m, tea.Batch(cmd, notifCmd)
+10
View File
@@ -13,6 +13,16 @@ import (
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
// Route non-key messages to textarea when editing so internal
// textarea messages (e.g. clipboard paste) are handled correctly.
if m.editing {
if _, ok := msg.(tea.KeyPressMsg); !ok {
var taCmd tea.Cmd
m.textarea, taCmd = m.textarea.Update(msg)
cmds = append(cmds, taCmd)
}
}
switch msg := msg.(type) {
case intercept.RequestArrivedMsg:
if !m.interceptEnabled {
+64 -24
View File
@@ -1,7 +1,6 @@
package plugins
import (
"os"
"strings"
"charm.land/bubbles/v2/help"
@@ -24,19 +23,20 @@ type Model struct {
filter string
filtered []plugins.Info
listViewport viewport.Model
textarea textarea.Model
filterInput textinput.Model
filtering bool
pager paginator.Model
help help.Model
listViewport viewport.Model
detailViewport 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 := style.NewTextarea(true)
ta.Placeholder = "plugin configuration..."
ta.Blur()
@@ -44,12 +44,13 @@ func New(mgr *plugins.Manager) Model {
fi.Prompt = ""
return Model{
manager: mgr,
listViewport: style.NewViewport(),
textarea: ta,
filterInput: fi,
pager: style.NewPaginator(),
help: style.NewHelp(),
manager: mgr,
listViewport: style.NewViewport(),
detailViewport: style.NewViewport(),
textarea: ta,
filterInput: fi,
pager: style.NewPaginator(),
help: style.NewHelp(),
}
}
@@ -88,10 +89,27 @@ func (m *Model) recalcSizes() {
}
m.filterInput.SetWidth(inner - 2)
detailContentH := style.PanelContentH(detailH)
const headerH = 2
const configFixedH = 2 // blank line + label line
textareaH := max(3, detailContentH/3)
if textareaH > 12 {
textareaH = 12
}
configTotalH := 0
if m.hasConfig() {
configTotalH = configFixedH + textareaH
}
descVH := max(1, detailContentH-headerH-configTotalH)
m.detailViewport.SetWidth(inner)
m.detailViewport.SetHeight(descVH)
m.textarea.SetWidth(max(1, inner-2))
m.textarea.SetHeight(max(3, detailH-6))
m.textarea.SetHeight(max(3, textareaH))
m.refreshListViewport()
m.syncDetailViewport()
}
// Refresh reloads the plugin list from the manager.
@@ -126,6 +144,7 @@ func (m *Model) applyFilter() {
}
m.refreshListViewport()
m.syncTextarea()
m.syncDetailViewport()
}
func (m *Model) selected() (plugins.Info, bool) {
@@ -135,6 +154,15 @@ func (m *Model) selected() (plugins.Info, bool) {
return m.filtered[m.cursor], true
}
func (m *Model) hasConfig() bool {
info, ok := m.selected()
if !ok {
return false
}
_, has := info.Hooks["on_config"]
return has
}
func (m *Model) syncTextarea() {
if m.editing {
return
@@ -147,6 +175,16 @@ func (m *Model) syncTextarea() {
m.textarea.SetValue(info.ConfigText)
}
func (m *Model) syncDetailViewport() {
info, ok := m.selected()
if !ok || info.Description == "" {
m.detailViewport.SetContent("")
return
}
desc := renderPluginDescription(info.Description, m.width-6)
m.detailViewport.SetContent(desc)
}
func (m *Model) refreshListViewport() {
if m.pager.PerPage > 0 {
m.pager.Page = m.cursor / m.pager.PerPage
@@ -155,16 +193,11 @@ func (m *Model) refreshListViewport() {
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
hasConfig bool
}
type pluginsKeyMap struct{ editing bool }
func (k pluginsKeyMap) ShortHelp() []key.Binding {
pk := keys.Keys.Plugins
g := keys.Keys.Global
@@ -172,7 +205,14 @@ func (k pluginsKeyMap) ShortHelp() []key.Binding {
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}
scrollHint := key.NewBinding(
key.WithKeys(g.ScrollUp.Keys()...),
key.WithHelp(g.ScrollUp.Help().Key+"/"+g.ScrollDown.Help().Key, "scroll detail"),
)
if k.hasConfig {
return []key.Binding{pk.Toggle, pk.EditConfig, pk.Filter, scrollHint}
}
return []key.Binding{pk.Toggle, pk.Filter, scrollHint}
}
func (k pluginsKeyMap) FullHelp() [][]key.Binding {
+39 -3
View File
@@ -21,7 +21,27 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
// Route non-key messages to textarea when editing so internal
// textarea messages (e.g. clipboard paste) are handled correctly.
if m.editing {
if _, ok := msg.(tea.KeyPressMsg); !ok {
var cmd tea.Cmd
m.textarea, cmd = m.textarea.Update(msg)
return m, cmd
}
}
switch msg := msg.(type) {
case tea.MouseWheelMsg:
if !m.editing {
switch msg.Button {
case tea.MouseWheelUp:
m.detailViewport.SetYOffset(m.detailViewport.YOffset() - 1)
case tea.MouseWheelDown:
m.detailViewport.SetYOffset(m.detailViewport.YOffset() + 1)
}
}
case tea.KeyPressMsg:
pk := keys.Keys.Plugins
g := keys.Keys.Global
@@ -90,15 +110,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, g.Up):
if m.cursor > 0 {
m.cursor--
m.refreshListViewport()
m.recalcSizes()
m.syncTextarea()
m.detailViewport.GotoTop()
}
case key.Matches(msg, g.Down):
if m.cursor < len(m.filtered)-1 {
m.cursor++
m.refreshListViewport()
m.recalcSizes()
m.syncTextarea()
m.detailViewport.GotoTop()
}
case key.Matches(msg, pk.Toggle):
@@ -115,11 +137,25 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case key.Matches(msg, pk.EditConfig):
if _, ok := m.selected(); ok {
if _, ok := m.selected(); ok && m.hasConfig() {
m.editing = true
m.textarea.Focus()
}
case key.Matches(msg, g.ScrollUp):
step := m.detailViewport.Height() / 2
if step < 1 {
step = 1
}
m.detailViewport.SetYOffset(m.detailViewport.YOffset() - step)
case key.Matches(msg, g.ScrollDown):
step := m.detailViewport.Height() / 2
if step < 1 {
step = 1
}
m.detailViewport.SetYOffset(m.detailViewport.YOffset() + step)
case key.Matches(msg, g.Help):
m.help.ShowAll = !m.help.ShowAll
m.recalcSizes()
+57 -18
View File
@@ -1,10 +1,13 @@
package plugins
import (
"path/filepath"
"strings"
"charm.land/glamour/v2"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/config"
"github.com/anotherhadi/spilltea/internal/icons"
"github.com/anotherhadi/spilltea/internal/keys"
"github.com/anotherhadi/spilltea/internal/style"
@@ -27,23 +30,29 @@ func (m Model) View() tea.View {
func (m *Model) renderListPanel(w, h int) string {
s := style.S
panelStyle := s.PanelFocused
if m.editing {
panelStyle = s.Panel
}
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)
return style.RenderWithTitle(panelStyle, icons.I.Plugin+"Plugins", inner, w, h)
}
func (m *Model) renderDetailPanel(h int) string {
s := style.S
panelStyle := s.Panel
if m.editing {
panelStyle = s.PanelFocused
}
info, ok := m.selected()
if !ok {
return style.RenderWithTitle(s.Panel, "Config", "", m.width, h)
return style.RenderWithTitle(panelStyle, "Detail", "", m.width, h)
}
var sb strings.Builder
statusSt := lipgloss.NewStyle().Foreground(s.Error)
if info.Enabled {
statusSt = lipgloss.NewStyle().Foreground(s.Success)
@@ -52,22 +61,52 @@ func (m *Model) renderDetailPanel(h int) string {
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):"))
pad := lipgloss.NewStyle().Padding(0, 1)
header := pad.Render(
s.Bold.Render(info.Name) + " " + statusSt.Render(status) + "\n" +
s.Faint.Render(filepath.Base(info.FilePath)),
)
parts := []string{header, m.detailViewport.View()}
if m.hasConfig() {
var configLabel string
if m.editing {
escKey := keys.Keys.Global.Escape.Help().Key
configLabel = pad.Render(s.Faint.Render("editing config (" + escKey + " to save):"))
} else {
editKey := keys.Keys.Plugins.EditConfig.Help().Key
configLabel = pad.Render(s.Faint.Render("config (" + editKey + " to edit):"))
}
parts = append(parts, "", configLabel, pad.Render(m.textarea.View()))
}
inner := lipgloss.JoinVertical(lipgloss.Left,
lipgloss.NewStyle().Padding(0, 1).Render(sb.String()),
lipgloss.NewStyle().Padding(0, 1).Render(m.textarea.View()),
inner := lipgloss.JoinVertical(lipgloss.Left, parts...)
return style.RenderWithTitle(panelStyle, "Detail", inner, m.width, h)
}
func renderPluginDescription(desc string, width int) string {
desc = strings.TrimSpace(desc)
lines := strings.Split(desc, "\n")
for i, l := range lines {
lines[i] = strings.TrimLeft(l, " \t")
}
desc = strings.Join(lines, "\n")
r, err := glamour.NewTermRenderer(
glamour.WithStyles(style.GlamourStyleConfig(config.Global)),
glamour.WithWordWrap(width),
)
return style.RenderWithTitle(s.Panel, "Detail", inner, m.width, h)
if err != nil {
return desc
}
out, err := r.Render(desc)
if err != nil {
return desc
}
return strings.Trim(out, "\n")
}
func (m *Model) renderStatusBar() string {
@@ -81,9 +120,9 @@ func (m *Model) renderStatusBar() string {
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 lipgloss.JoinVertical(lipgloss.Left, filterLine, pad.Render(m.help.View(pluginsKeyMap{editing: m.editing, hasConfig: m.hasConfig()})))
}
return pad.Render(m.help.View(pluginsKeyMap{editing: m.editing}))
return pad.Render(m.help.View(pluginsKeyMap{editing: m.editing, hasConfig: m.hasConfig()}))
}
func (m *Model) renderList() string {
+13 -1
View File
@@ -33,6 +33,18 @@ func sendCmd(entry Entry, index int) tea.Cmd {
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
// Route non-key messages to textarea when editing so internal
// textarea messages (e.g. clipboard paste) are handled correctly.
if m.editing {
if _, ok := msg.(tea.KeyPressMsg); !ok {
var taCmd tea.Cmd
m.textarea, taCmd = m.textarea.Update(msg)
cmds = append(cmds, taCmd)
}
}
switch msg := msg.(type) {
case SendToReplayMsg:
entry := entryFromMsg(msg)
@@ -104,7 +116,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateNormalMode(msg)
}
return m, nil
return m, tea.Batch(cmds...)
}
func (m Model) updateNormalMode(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
+5 -1
View File
@@ -33,12 +33,16 @@ func (m Model) View() tea.View {
func (m *Model) renderListPanel(w, h int) string {
s := style.S
panelStyle := s.PanelFocused
if m.editing {
panelStyle = s.Panel
}
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.Replay+"Replay", inner, w, h)
return style.RenderWithTitle(panelStyle, icons.I.Replay+"Replay", inner, w, h)
}
func (m *Model) renderRequestPanel(w, h int) string {