mirror of
https://github.com/anotherhadi/spilltea.git
synced 2026-05-20 01:32:33 +02:00
Change plugins behavior
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user