Files
seanime-docker/seanime-2.9.10/internal/plugin/ui/command.go
2025-09-20 14:08:38 +01:00

381 lines
11 KiB
Go

package plugin_ui
import (
goja_util "seanime/internal/util/goja"
"seanime/internal/util/result"
"slices"
"sync"
"time"
"github.com/dop251/goja"
"github.com/google/uuid"
)
// CommandPaletteManager is a manager for the command palette.
// Unlike the Tray, command palette items are not reactive to state changes.
// They are only rendered when the setItems function is called or the refresh function is called.
type CommandPaletteManager struct {
ctx *Context
updateMutex sync.Mutex
lastUpdated time.Time
componentManager *ComponentManager
placeholder string
keyboardShortcut string
// registered is true if the command palette has been registered
registered bool
items *result.Map[string, *commandItem]
renderedItems []*CommandItemJSON // Store rendered items when setItems is called
}
type (
commandItem struct {
index int
id string
label string
value string
filterType string // "includes" or "startsWith" or ""
heading string
renderFunc func(goja.FunctionCall) goja.Value
onSelectFunc func(goja.FunctionCall) goja.Value
}
// CommandItemJSON is the JSON representation of a command item.
// It is used to send the command item to the client.
CommandItemJSON struct {
Index int `json:"index"`
ID string `json:"id"`
Label string `json:"label"`
Value string `json:"value"`
FilterType string `json:"filterType"`
Heading string `json:"heading"`
Components interface{} `json:"components"`
}
)
func NewCommandPaletteManager(ctx *Context) *CommandPaletteManager {
return &CommandPaletteManager{
ctx: ctx,
componentManager: &ComponentManager{ctx: ctx},
items: result.NewResultMap[string, *commandItem](),
renderedItems: make([]*CommandItemJSON, 0),
}
}
type NewCommandPaletteOptions struct {
Placeholder string `json:"placeholder,omitempty"`
KeyboardShortcut string `json:"keyboardShortcut,omitempty"`
}
// sendInfoToClient sends the command palette info to the client after it's been requested.
func (c *CommandPaletteManager) sendInfoToClient() {
if c.registered {
c.ctx.SendEventToClient(ServerCommandPaletteInfoEvent, ServerCommandPaletteInfoEventPayload{
Placeholder: c.placeholder,
KeyboardShortcut: c.keyboardShortcut,
})
}
}
func (c *CommandPaletteManager) jsNewCommandPalette(options NewCommandPaletteOptions) goja.Value {
c.registered = true
c.keyboardShortcut = options.KeyboardShortcut
c.placeholder = options.Placeholder
cmdObj := c.ctx.vm.NewObject()
_ = cmdObj.Set("setItems", func(items []interface{}) {
c.items.Clear()
for idx, item := range items {
itemMap := item.(map[string]interface{})
id := uuid.New().String()
label, _ := itemMap["label"].(string)
value, ok := itemMap["value"].(string)
if !ok {
c.ctx.handleTypeError("value must be a string")
return
}
filterType, _ := itemMap["filterType"].(string)
if filterType != "includes" && filterType != "startsWith" && filterType != "" {
c.ctx.handleTypeError("filterType must be 'includes', 'startsWith'")
return
}
heading, _ := itemMap["heading"].(string)
renderFunc, ok := itemMap["render"].(func(goja.FunctionCall) goja.Value)
if len(label) == 0 && !ok {
c.ctx.handleTypeError("label or render function must be provided")
return
}
onSelectFunc, ok := itemMap["onSelect"].(func(goja.FunctionCall) goja.Value)
if !ok {
c.ctx.handleTypeError("onSelect must be a function")
return
}
c.items.Set(id, &commandItem{
index: idx,
id: id,
label: label,
value: value,
filterType: filterType,
heading: heading,
renderFunc: renderFunc,
onSelectFunc: onSelectFunc,
})
}
// Convert the items to JSON
itemsJSON := make([]*CommandItemJSON, 0)
c.items.Range(func(key string, value *commandItem) bool {
itemsJSON = append(itemsJSON, value.ToJSON(c.ctx, c.componentManager, c.ctx.scheduler))
return true
})
// Store the converted items
c.renderedItems = itemsJSON
c.renderCommandPaletteScheduled()
})
_ = cmdObj.Set("refresh", func() {
// Convert the items to JSON
itemsJSON := make([]*CommandItemJSON, 0)
c.items.Range(func(key string, value *commandItem) bool {
itemsJSON = append(itemsJSON, value.ToJSON(c.ctx, c.componentManager, c.ctx.scheduler))
return true
})
c.renderedItems = itemsJSON
c.renderCommandPaletteScheduled()
})
_ = cmdObj.Set("setPlaceholder", func(placeholder string) {
c.placeholder = placeholder
c.renderCommandPaletteScheduled()
})
_ = cmdObj.Set("open", func() {
c.ctx.SendEventToClient(ServerCommandPaletteOpenEvent, ServerCommandPaletteOpenEventPayload{})
})
_ = cmdObj.Set("close", func() {
c.ctx.SendEventToClient(ServerCommandPaletteCloseEvent, ServerCommandPaletteCloseEventPayload{})
})
_ = cmdObj.Set("setInput", func(input string) {
c.ctx.SendEventToClient(ServerCommandPaletteSetInputEvent, ServerCommandPaletteSetInputEventPayload{
Value: input,
})
})
_ = cmdObj.Set("getInput", func() string {
c.ctx.SendEventToClient(ServerCommandPaletteGetInputEvent, ServerCommandPaletteGetInputEventPayload{})
eventListener := c.ctx.RegisterEventListener(ClientCommandPaletteInputEvent)
defer c.ctx.UnregisterEventListener(eventListener.ID)
timeout := time.After(1500 * time.Millisecond)
input := make(chan string)
eventListener.SetCallback(func(event *ClientPluginEvent) {
payload := ClientCommandPaletteInputEventPayload{}
if event.ParsePayloadAs(ClientCommandPaletteInputEvent, &payload) {
input <- payload.Value
}
})
// go func() {
// for event := range eventListener.Channel {
// if event.ParsePayloadAs(ClientCommandPaletteInputEvent, &payload) {
// input <- payload.Value
// }
// }
// }()
select {
case <-timeout:
return ""
case input := <-input:
return input
}
})
// jsOnOpen
//
// Example:
// commandPalette.onOpen(() => {
// console.log("command palette opened by the user")
// })
_ = cmdObj.Set("onOpen", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
c.ctx.handleTypeError("onOpen requires a callback function")
}
callback, ok := goja.AssertFunction(call.Argument(0))
if !ok {
c.ctx.handleTypeError("onOpen requires a callback function")
}
eventListener := c.ctx.RegisterEventListener(ClientCommandPaletteOpenedEvent)
eventListener.SetCallback(func(event *ClientPluginEvent) {
payload := ClientCommandPaletteOpenedEventPayload{}
if event.ParsePayloadAs(ClientCommandPaletteOpenedEvent, &payload) {
c.ctx.scheduler.ScheduleAsync(func() error {
_, err := callback(goja.Undefined(), c.ctx.vm.ToValue(map[string]interface{}{}))
return err
})
}
})
// go func() {
// for event := range eventListener.Channel {
// if event.ParsePayloadAs(ClientCommandPaletteOpenedEvent, &payload) {
// c.ctx.scheduler.ScheduleAsync(func() error {
// _, err := callback(goja.Undefined(), c.ctx.vm.ToValue(map[string]interface{}{}))
// if err != nil {
// c.ctx.logger.Error().Err(err).Msg("plugin: Error running command palette open callback")
// }
// return err
// })
// }
// }
// }()
return goja.Undefined()
})
// jsOnClose
//
// Example:
// commandPalette.onClose(() => {
// console.log("command palette closed by the user")
// })
_ = cmdObj.Set("onClose", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
c.ctx.handleTypeError("onClose requires a callback function")
}
callback, ok := goja.AssertFunction(call.Argument(0))
if !ok {
c.ctx.handleTypeError("onClose requires a callback function")
}
eventListener := c.ctx.RegisterEventListener(ClientCommandPaletteClosedEvent)
eventListener.SetCallback(func(event *ClientPluginEvent) {
payload := ClientCommandPaletteClosedEventPayload{}
if event.ParsePayloadAs(ClientCommandPaletteClosedEvent, &payload) {
c.ctx.scheduler.ScheduleAsync(func() error {
_, err := callback(goja.Undefined(), c.ctx.vm.ToValue(map[string]interface{}{}))
return err
})
}
})
// go func() {
// for event := range eventListener.Channel {
// if event.ParsePayloadAs(ClientCommandPaletteClosedEvent, &payload) {
// c.ctx.scheduler.ScheduleAsync(func() error {
// _, err := callback(goja.Undefined(), c.ctx.vm.ToValue(map[string]interface{}{}))
// if err != nil {
// c.ctx.logger.Error().Err(err).Msg("plugin: Error running command palette close callback")
// }
// return err
// })
// }
// }
// }()
return goja.Undefined()
})
eventListener := c.ctx.RegisterEventListener(ClientCommandPaletteItemSelectedEvent)
eventListener.SetCallback(func(event *ClientPluginEvent) {
payload := ClientCommandPaletteItemSelectedEventPayload{}
if event.ParsePayloadAs(ClientCommandPaletteItemSelectedEvent, &payload) {
c.ctx.scheduler.ScheduleAsync(func() error {
item, found := c.items.Get(payload.ItemID)
if found {
_ = item.onSelectFunc(goja.FunctionCall{})
}
return nil
})
}
})
// go func() {
// eventListener := c.ctx.RegisterEventListener(ClientCommandPaletteItemSelectedEvent)
// payload := ClientCommandPaletteItemSelectedEventPayload{}
// for event := range eventListener.Channel {
// if event.ParsePayloadAs(ClientCommandPaletteItemSelectedEvent, &payload) {
// item, found := c.items.Get(payload.ItemID)
// if found {
// c.ctx.scheduler.ScheduleAsync(func() error {
// _ = item.onSelectFunc(goja.FunctionCall{})
// return nil
// })
// }
// }
// }
// }()
// Register components
_ = cmdObj.Set("div", c.componentManager.jsDiv)
_ = cmdObj.Set("flex", c.componentManager.jsFlex)
_ = cmdObj.Set("stack", c.componentManager.jsStack)
_ = cmdObj.Set("text", c.componentManager.jsText)
_ = cmdObj.Set("button", c.componentManager.jsButton)
_ = cmdObj.Set("anchor", c.componentManager.jsAnchor)
return cmdObj
}
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func (c *commandItem) ToJSON(ctx *Context, componentManager *ComponentManager, scheduler *goja_util.Scheduler) *CommandItemJSON {
var components interface{}
if c.renderFunc != nil {
var err error
components, err = componentManager.renderComponents(c.renderFunc)
if err != nil {
ctx.logger.Error().Err(err).Msg("plugin: Failed to render command palette item")
ctx.handleException(err)
return nil
}
}
// Reset the last rendered components, we don't care about diffing
componentManager.lastRenderedComponents = nil
return &CommandItemJSON{
Index: c.index,
ID: c.id,
Label: c.label,
Value: c.value,
FilterType: c.filterType,
Heading: c.heading,
Components: components,
}
}
func (c *CommandPaletteManager) renderCommandPaletteScheduled() {
c.updateMutex.Lock()
defer c.updateMutex.Unlock()
if !c.registered {
return
}
slices.SortFunc(c.renderedItems, func(a, b *CommandItemJSON) int {
return a.Index - b.Index
})
c.ctx.SendEventToClient(ServerCommandPaletteUpdatedEvent, ServerCommandPaletteUpdatedEventPayload{
Placeholder: c.placeholder,
Items: c.renderedItems,
})
}