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, }) }