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
+6
View File
@@ -68,6 +68,12 @@ CREATE TABLE IF NOT EXISTS replay_entries (
return err
}
// Query executes a SQL query and returns the rows. The caller must close the
// returned rows. Args are passed as positional parameters.
func (d *DB) Query(query string, args ...any) (*sql.Rows, error) {
return d.conn.Query(query, args...)
}
func (d *DB) Close() error {
if d == nil {
return nil
+14 -3
View File
@@ -44,7 +44,12 @@ type Broker struct {
autoFwdMu sync.RWMutex
autoFwdRegexes []*regexp.Regexp
onNewEntry func(db.Entry)
onBeforeNewEntry func(db.Entry) bool
onNewEntry func(db.Entry)
}
func (b *Broker) SetOnBeforeNewEntry(cb func(db.Entry) bool) {
b.onBeforeNewEntry = cb
}
func (b *Broker) SetOnNewEntry(cb func(db.Entry)) {
@@ -165,7 +170,7 @@ func (b *Broker) SaveEntry(f *proxy.Flow) {
return
}
}
entry, err := d.InsertEntry(db.Entry{
pending := db.Entry{
Timestamp: time.Now(),
Method: r.Method,
Host: r.URL.Host,
@@ -173,7 +178,13 @@ func (b *Broker) SaveEntry(f *proxy.Flow) {
StatusCode: status,
RequestRaw: FormatRawRequest(f),
ResponseRaw: FormatRawResponse(f),
})
}
if cb := b.onBeforeNewEntry; cb != nil {
if !cb(pending) {
return
}
}
entry, err := d.InsertEntry(pending)
if err == nil {
if cb := b.onNewEntry; cb != nil {
go cb(entry)
+83 -2
View File
@@ -26,8 +26,9 @@ func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
L.SetGlobal("notif", L.NewFunction(func(L *lua.LState) int {
title := L.CheckString(1)
body := L.CheckString(2)
kind := L.OptString(3, "info")
select {
case mgr.Notifs <- PluginNotifMsg{Title: title, Body: body}:
case mgr.Notifs <- PluginNotifMsg{Title: title, Body: body, Kind: kind}:
default:
}
return 0
@@ -64,7 +65,87 @@ func registerUtilities(L *lua.LState, mgr *Manager, p *Plugin) {
return 0
}))
L.SetGlobal("quit", L.NewFunction(func(L *lua.LState) int {
L.SetGlobal("db_query", L.NewFunction(func(L *lua.LState) int {
if mgr.db == nil {
L.Push(lua.LNil)
L.Push(lua.LString("db not available"))
return 2
}
query := L.CheckString(1)
var args []any
for i := 2; i <= L.GetTop(); i++ {
switch v := L.Get(i).(type) {
case lua.LString:
args = append(args, string(v))
case lua.LNumber:
args = append(args, float64(v))
case lua.LBool:
args = append(args, bool(v))
default:
args = append(args, nil)
}
}
rows, err := mgr.db.Query(query, args...)
if err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
defer rows.Close()
cols, err := rows.Columns()
if err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
result := L.NewTable()
rowIdx := 1
for rows.Next() {
vals := make([]any, len(cols))
ptrs := make([]any, len(cols))
for i := range vals {
ptrs[i] = &vals[i]
}
if err := rows.Scan(ptrs...); err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
row := L.NewTable()
for i, col := range cols {
switch v := vals[i].(type) {
case int64:
L.SetField(row, col, lua.LNumber(v))
case float64:
L.SetField(row, col, lua.LNumber(v))
case string:
L.SetField(row, col, lua.LString(v))
case []byte:
L.SetField(row, col, lua.LString(string(v)))
case bool:
if v {
L.SetField(row, col, lua.LTrue)
} else {
L.SetField(row, col, lua.LFalse)
}
case nil:
L.SetField(row, col, lua.LNil)
}
}
L.RawSetInt(result, rowIdx, row)
rowIdx++
}
if err := rows.Err(); err != nil {
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
L.Push(result)
L.Push(lua.LNil)
return 2
}))
L.SetGlobal("quit", L.NewFunction(func(L *lua.LState) int {
reason := L.OptString(1, "plugin requested quit")
select {
case mgr.Quit <- reason:
+97 -39
View File
@@ -5,6 +5,7 @@ import (
"log"
"os"
"path/filepath"
"sort"
"strings"
"sync"
@@ -27,12 +28,13 @@ type Manager struct {
func NewManager(broker *intercept.Broker) *Manager {
mgr := &Manager{
broker: broker,
broker: broker,
Notifs: make(chan PluginNotifMsg, 64),
Quit: make(chan string, 4),
}
if broker != nil {
broker.SetOnNewEntry(mgr.RunOnHistoryEntry)
broker.SetOnBeforeNewEntry(mgr.RunSyncOnHistoryEntry)
broker.SetOnNewEntry(mgr.RunAsyncOnHistoryEntry)
}
return mgr
}
@@ -107,27 +109,41 @@ func (m *Manager) loadPlugin(path string) (*Plugin, error) {
p.Name = strings.TrimSuffix(filepath.Base(path), ".lua")
}
// Defaults when not overridden by the Plugin table.
hookDefaults := map[string]bool{
"on_start": true, // always sync
"on_request": false, // async
"on_response": false, // async
"on_quit": true, // always sync
"on_history_entry": false, // always async
if s, ok := pluginTable.RawGetString("description").(lua.LString); ok {
p.Description = string(s)
}
for hookName, defaultSync := range hookDefaults {
// Plugin table entry overrides the default (except on_start/on_quit/on_history_entry which are fixed).
if hookName != "on_start" && hookName != "on_quit" && hookName != "on_history_entry" {
if tbl, ok := pluginTable.RawGetString(hookName).(*lua.LTable); ok {
p.hooks[hookName] = HookConfig{Sync: tbl.RawGetString("sync") == lua.LTrue}
continue
}
if n, ok := pluginTable.RawGetString("priority").(lua.LNumber); ok {
p.Priority = int(n)
}
// Hooks configurable via the Plugin table (sync field).
configurableHooks := map[string]bool{
"on_start": false, // async by default
"on_request": false,
"on_response": false,
"on_history_entry": false,
}
// Fixed-sync hooks: always sync, not configurable.
fixedSyncHooks := map[string]struct{}{
"on_config": {},
"on_quit": {},
}
for hookName, defaultSync := range configurableHooks {
if tbl, ok := pluginTable.RawGetString(hookName).(*lua.LTable); ok {
p.hooks[hookName] = HookConfig{Sync: tbl.RawGetString("sync") == lua.LTrue}
continue
}
// Auto-detect: register the hook if the function exists as a global.
if p.L.GetGlobal(hookName) != lua.LNil {
p.hooks[hookName] = HookConfig{Sync: defaultSync}
}
}
for hookName := range fixedSyncHooks {
if p.L.GetGlobal(hookName) != lua.LNil {
p.hooks[hookName] = HookConfig{Sync: true}
}
}
return p, nil
}
@@ -137,6 +153,7 @@ func (m *Manager) GetPlugins() []*Plugin {
defer m.mu.RUnlock()
out := make([]*Plugin, len(m.plugins))
copy(out, m.plugins)
sort.Slice(out, func(i, j int) bool { return out[i].Priority > out[j].Priority })
return out
}
@@ -179,46 +196,62 @@ func (m *Manager) SaveConfig(name, configText string) {
found.mu.Lock()
found.ConfigText = configText
enabled := found.Enabled
hc, hasOnStart := found.hooks["on_start"]
_, hasOnConfig := found.hooks["on_config"]
found.mu.Unlock()
if m.db != nil {
_ = m.db.SavePluginState(name, enabled, configText)
}
if !hasOnStart {
if !hasOnConfig {
return
}
// Re-run on_start so the plugin can re-parse the new config.
if hc.Sync {
found.mu.Lock()
if _, err := callHook(found, "on_start", lua.LString(configText)); err != nil {
log.Printf("plugin %s on_start (config reload): %v", name, err)
}
found.mu.Unlock()
} else {
go func() {
found.mu.Lock()
if _, err := callHook(found, "on_start", lua.LString(configText)); err != nil {
log.Printf("plugin %s on_start (config reload): %v", name, err)
}
found.mu.Unlock()
}()
// on_config is always sync.
found.mu.Lock()
if _, err := callHook(found, "on_config", lua.LString(configText)); err != nil {
log.Printf("plugin %s on_config (config reload): %v", name, err)
}
found.mu.Unlock()
}
func (m *Manager) RunOnStart() {
// on_config runs first, always sync, for every enabled plugin that has it.
for _, p := range m.GetPlugins() {
if !p.Enabled {
continue
}
if _, ok := p.hooks["on_start"]; !ok {
if _, ok := p.hooks["on_config"]; !ok {
continue
}
p.mu.Lock()
if _, err := callHook(p, "on_start", lua.LString(p.ConfigText)); err != nil {
log.Printf("plugin %s on_start: %v", p.Name, err)
if _, err := callHook(p, "on_config", lua.LString(p.ConfigText)); err != nil {
log.Printf("plugin %s on_config: %v", p.Name, err)
}
p.mu.Unlock()
}
// on_start runs after, sync or async depending on plugin config.
for _, p := range m.GetPlugins() {
if !p.Enabled {
continue
}
hc, ok := p.hooks["on_start"]
if !ok {
continue
}
if hc.Sync {
p.mu.Lock()
if _, err := callHook(p, "on_start"); err != nil {
log.Printf("plugin %s on_start: %v", p.Name, err)
}
p.mu.Unlock()
} else {
go func(p *Plugin) {
p.mu.Lock()
if _, err := callHook(p, "on_start"); err != nil {
log.Printf("plugin %s on_start: %v", p.Name, err)
}
p.mu.Unlock()
}(p)
}
}
}
func (m *Manager) RunOnQuit() {
@@ -327,12 +360,37 @@ func (m *Manager) RunAsyncOnResponse(f *goproxy.Flow) {
}
}
func (m *Manager) RunOnHistoryEntry(e db.Entry) {
// RunSyncOnHistoryEntry is called before DB insert; returns false to skip saving.
func (m *Manager) RunSyncOnHistoryEntry(e db.Entry) bool {
for _, p := range m.GetPlugins() {
if !p.Enabled {
continue
}
if _, ok := p.hooks["on_history_entry"]; !ok {
hc, ok := p.hooks["on_history_entry"]
if !ok || !hc.Sync {
continue
}
p.mu.Lock()
result, err := callHook(p, "on_history_entry", pushEntry(p.L, e))
p.mu.Unlock()
if err != nil {
log.Printf("plugin %s on_history_entry: %v", p.Name, err)
continue
}
if result == "skip" {
return false
}
}
return true
}
func (m *Manager) RunAsyncOnHistoryEntry(e db.Entry) {
for _, p := range m.GetPlugins() {
if !p.Enabled {
continue
}
hc, ok := p.hooks["on_history_entry"]
if !ok || hc.Sync {
continue
}
go func(p *Plugin) {
+26 -14
View File
@@ -11,10 +11,12 @@ type HookConfig struct {
}
type Plugin struct {
Name string
FilePath string
Enabled bool
ConfigText string
Name string
Description string
FilePath string
Enabled bool
ConfigText string
Priority int
L *lua.LState
mu sync.Mutex
@@ -35,30 +37,40 @@ func (p *Plugin) HookConfig(name string) (HookConfig, bool) {
}
type Info struct {
Name string
FilePath string
Enabled bool
ConfigText string
Hooks map[string]HookConfig
Name string
Description string
FilePath string
Enabled bool
ConfigText string
Priority int
Hooks map[string]HookConfig
}
func (p *Plugin) Info() Info {
p.mu.Lock()
enabled := p.Enabled
configText := p.ConfigText
p.mu.Unlock()
hooks := make(map[string]HookConfig, len(p.hooks))
for k, v := range p.hooks {
hooks[k] = v
}
return Info{
Name: p.Name,
FilePath: p.FilePath,
Enabled: p.Enabled,
ConfigText: p.ConfigText,
Hooks: hooks,
Name: p.Name,
Description: p.Description,
FilePath: p.FilePath,
Enabled: enabled,
ConfigText: configText,
Priority: p.Priority,
Hooks: hooks,
}
}
type PluginNotifMsg struct {
Title string
Body string
Kind string // "info", "success", "warning", "error"
}
type PluginQuitMsg struct {
+7 -2
View File
@@ -28,15 +28,20 @@ func NewTextarea(showLineNumbers bool) textarea.Model {
ta.Prompt = ""
ta.ShowLineNumbers = showLineNumbers
ta.CharLimit = 0
ta.EndOfBufferCharacter = '~'
ts := ta.Styles()
ts.Focused.Base = lipgloss.NewStyle()
ts.Blurred.Base = lipgloss.NewStyle()
ts.Focused.Text = lipgloss.NewStyle().Foreground(S.Text)
ts.Focused.CursorLine = lipgloss.NewStyle().Background(S.Selection).Foreground(S.Text)
ts.Focused.CursorLineNumber = lipgloss.NewStyle().Background(S.Selection).Foreground(S.Primary).Bold(true)
ts.Focused.LineNumber = lipgloss.NewStyle().Foreground(S.Subtle)
ts.Focused.Placeholder = lipgloss.NewStyle().Foreground(S.Subtle)
ts.Blurred.Placeholder = lipgloss.NewStyle().Foreground(S.Subtle)
ts.Focused.EndOfBuffer = lipgloss.NewStyle().Foreground(S.SubtleBg)
ts.Blurred.EndOfBuffer = lipgloss.NewStyle().Foreground(S.SubtleBg)
ts.Blurred.Text = lipgloss.NewStyle().Foreground(S.MutedFg)
ts.Blurred.LineNumber = lipgloss.NewStyle().Foreground(S.SubtleBg)
ts.Blurred.Placeholder = lipgloss.NewStyle().Foreground(S.Subtle)
ts.Blurred.EndOfBuffer = lipgloss.NewStyle().Foreground(S.SubtleBg)
ta.SetStyles(ts)
return ta
}
+6
View File
@@ -24,6 +24,7 @@ type Styles struct {
Panel lipgloss.Style
PanelFocused lipgloss.Style
PanelEditing lipgloss.Style
PagerDotActive string
PagerDotInactive string
@@ -43,6 +44,7 @@ func Init(cfg *config.Config) {
warning := lipgloss.Color("#" + c.Base09) // Orange: warnings
success := lipgloss.Color("#" + c.Base0B) // Green: success
primary := lipgloss.Color("#" + c.Base0D) // Accent: primary
purple := lipgloss.Color("#" + c.Base0E) // Purple: editing
S = &Styles{
Primary: primary,
@@ -66,6 +68,10 @@ func Init(cfg *config.Config) {
Border(lipgloss.RoundedBorder()).
BorderForeground(primary),
PanelEditing: lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(purple),
PagerDotActive: lipgloss.NewStyle().Foreground(primary).SetString("•").String(),
PagerDotInactive: lipgloss.NewStyle().Foreground(subtle).SetString("•").String(),
}
+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 {