From 94c17b88c2b3562a9fa0c95ef6a6f0a67c0c9487 Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 13 Mar 2026 14:51:33 +0000 Subject: [PATCH] feat(contextmenu): add contextmenu core.Service with Platform interface and IPC Completes full Wails v3 Manager API coverage through the IPC bus. Service maintains in-memory registry, delegates to Platform for native context menu operations, broadcasts ActionItemClicked on menu item clicks. Co-Authored-By: Claude Opus 4.6 --- pkg/contextmenu/messages.go | 44 +++++ pkg/contextmenu/platform.go | 41 +++++ pkg/contextmenu/register.go | 16 ++ pkg/contextmenu/service.go | 114 ++++++++++++ pkg/contextmenu/service_test.go | 299 ++++++++++++++++++++++++++++++++ 5 files changed, 514 insertions(+) create mode 100644 pkg/contextmenu/messages.go create mode 100644 pkg/contextmenu/platform.go create mode 100644 pkg/contextmenu/register.go create mode 100644 pkg/contextmenu/service.go create mode 100644 pkg/contextmenu/service_test.go diff --git a/pkg/contextmenu/messages.go b/pkg/contextmenu/messages.go new file mode 100644 index 0000000..cb62e17 --- /dev/null +++ b/pkg/contextmenu/messages.go @@ -0,0 +1,44 @@ +// pkg/contextmenu/messages.go +package contextmenu + +import "errors" + +// ErrMenuNotFound is returned when attempting to remove or get a menu +// that does not exist in the registry. +var ErrMenuNotFound = errors.New("contextmenu: menu not found") + +// --- Queries --- + +// QueryGet returns a single context menu by name. Result: *ContextMenuDef (nil if not found) +type QueryGet struct { + Name string `json:"name"` +} + +// QueryList returns all registered context menus. Result: map[string]ContextMenuDef +type QueryList struct{} + +// --- Tasks --- + +// TaskAdd registers a context menu. Result: nil +// If a menu with the same name already exists it is replaced (remove + re-add). +type TaskAdd struct { + Name string `json:"name"` + Menu ContextMenuDef `json:"menu"` +} + +// TaskRemove unregisters a context menu. Result: nil +// Returns ErrMenuNotFound if the menu does not exist. +type TaskRemove struct { + Name string `json:"name"` +} + +// --- Actions --- + +// ActionItemClicked is broadcast when a context menu item is clicked. +// The Data field is populated from the CSS --custom-contextmenu-data property +// on the element that triggered the context menu. +type ActionItemClicked struct { + MenuName string `json:"menuName"` + ActionID string `json:"actionId"` + Data string `json:"data,omitempty"` +} diff --git a/pkg/contextmenu/platform.go b/pkg/contextmenu/platform.go new file mode 100644 index 0000000..d98f755 --- /dev/null +++ b/pkg/contextmenu/platform.go @@ -0,0 +1,41 @@ +// pkg/contextmenu/platform.go +package contextmenu + +// Platform abstracts the context menu backend (Wails v3). +// The Add callback must broadcast ActionItemClicked via s.Core().ACTION() +// when a menu item is clicked — the adapter translates MenuItemDef.ActionID +// to a callback that does this. +type Platform interface { + // Add registers a context menu by name. + // The onItemClick callback is called with (menuName, actionID, data) + // when any item in the menu is clicked. The adapter creates per-item + // OnClick handlers that call this with the appropriate ActionID. + Add(name string, menu ContextMenuDef, onItemClick func(menuName, actionID, data string)) error + + // Remove unregisters a context menu by name. + Remove(name string) error + + // Get returns a context menu definition by name, or false if not found. + Get(name string) (*ContextMenuDef, bool) + + // GetAll returns all registered context menu definitions. + GetAll() map[string]ContextMenuDef +} + +// ContextMenuDef describes a context menu and its items. +type ContextMenuDef struct { + Name string `json:"name"` + Items []MenuItemDef `json:"items"` +} + +// MenuItemDef describes a single item in a context menu. +// Items may be nested (submenu children via Items field). +type MenuItemDef struct { + Label string `json:"label"` + Type string `json:"type,omitempty"` // "" (normal), "separator", "checkbox", "radio", "submenu" + Accelerator string `json:"accelerator,omitempty"` + Enabled *bool `json:"enabled,omitempty"` // nil = true (default) + Checked bool `json:"checked,omitempty"` + ActionID string `json:"actionId,omitempty"` // identifies which item was clicked + Items []MenuItemDef `json:"items,omitempty"` // submenu children (recursive) +} diff --git a/pkg/contextmenu/register.go b/pkg/contextmenu/register.go new file mode 100644 index 0000000..afb0604 --- /dev/null +++ b/pkg/contextmenu/register.go @@ -0,0 +1,16 @@ +// pkg/contextmenu/register.go +package contextmenu + +import "forge.lthn.ai/core/go/pkg/core" + +// Register creates a factory closure that captures the Platform adapter. +// The returned function has the signature WithService requires: func(*Core) (any, error). +func Register(p Platform) func(*core.Core) (any, error) { + return func(c *core.Core) (any, error) { + return &Service{ + ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}), + platform: p, + menus: make(map[string]ContextMenuDef), + }, nil + } +} diff --git a/pkg/contextmenu/service.go b/pkg/contextmenu/service.go new file mode 100644 index 0000000..973346d --- /dev/null +++ b/pkg/contextmenu/service.go @@ -0,0 +1,114 @@ +// pkg/contextmenu/service.go +package contextmenu + +import ( + "context" + "fmt" + + "forge.lthn.ai/core/go/pkg/core" +) + +// Options holds configuration for the context menu service. +type Options struct{} + +// Service is a core.Service managing context menus via IPC. +// It maintains an in-memory registry of menus (map[string]ContextMenuDef) +// and delegates platform-level registration to the Platform interface. +type Service struct { + *core.ServiceRuntime[Options] + platform Platform + menus map[string]ContextMenuDef +} + +// OnStartup registers IPC handlers. +func (s *Service) OnStartup(ctx context.Context) error { + s.Core().RegisterQuery(s.handleQuery) + s.Core().RegisterTask(s.handleTask) + return nil +} + +// HandleIPCEvents is auto-discovered and registered by core.WithService. +func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error { + return nil +} + +// --- Query Handlers --- + +func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) { + switch q := q.(type) { + case QueryGet: + return s.queryGet(q), true, nil + case QueryList: + return s.queryList(), true, nil + default: + return nil, false, nil + } +} + +// queryGet returns a single menu definition by name, or nil if not found. +func (s *Service) queryGet(q QueryGet) *ContextMenuDef { + menu, ok := s.menus[q.Name] + if !ok { + return nil + } + return &menu +} + +// queryList returns a copy of all registered menus. +func (s *Service) queryList() map[string]ContextMenuDef { + result := make(map[string]ContextMenuDef, len(s.menus)) + for k, v := range s.menus { + result[k] = v + } + return result +} + +// --- Task Handlers --- + +func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { + switch t := t.(type) { + case TaskAdd: + return nil, true, s.taskAdd(t) + case TaskRemove: + return nil, true, s.taskRemove(t) + default: + return nil, false, nil + } +} + +func (s *Service) taskAdd(t TaskAdd) error { + // If menu already exists, remove it first (replace semantics) + if _, exists := s.menus[t.Name]; exists { + _ = s.platform.Remove(t.Name) + delete(s.menus, t.Name) + } + + // Register on platform with a callback that broadcasts ActionItemClicked + err := s.platform.Add(t.Name, t.Menu, func(menuName, actionID, data string) { + _ = s.Core().ACTION(ActionItemClicked{ + MenuName: menuName, + ActionID: actionID, + Data: data, + }) + }) + if err != nil { + return fmt.Errorf("contextmenu: platform add failed: %w", err) + } + + s.menus[t.Name] = t.Menu + return nil +} + +func (s *Service) taskRemove(t TaskRemove) error { + if _, exists := s.menus[t.Name]; !exists { + return ErrMenuNotFound + } + + err := s.platform.Remove(t.Name) + if err != nil { + return fmt.Errorf("contextmenu: platform remove failed: %w", err) + } + + delete(s.menus, t.Name) + return nil +} diff --git a/pkg/contextmenu/service_test.go b/pkg/contextmenu/service_test.go new file mode 100644 index 0000000..93dd8d3 --- /dev/null +++ b/pkg/contextmenu/service_test.go @@ -0,0 +1,299 @@ +// pkg/contextmenu/service_test.go +package contextmenu + +import ( + "context" + "sync" + "testing" + + "forge.lthn.ai/core/go/pkg/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockPlatform records Add/Remove calls and allows simulating clicks. +type mockPlatform struct { + mu sync.Mutex + menus map[string]ContextMenuDef + clickHandlers map[string]func(menuName, actionID, data string) + removed []string + addErr error + removeErr error +} + +func newMockPlatform() *mockPlatform { + return &mockPlatform{ + menus: make(map[string]ContextMenuDef), + clickHandlers: make(map[string]func(menuName, actionID, data string)), + } +} + +func (m *mockPlatform) Add(name string, menu ContextMenuDef, onItemClick func(string, string, string)) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.addErr != nil { + return m.addErr + } + m.menus[name] = menu + m.clickHandlers[name] = onItemClick + return nil +} + +func (m *mockPlatform) Remove(name string) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.removeErr != nil { + return m.removeErr + } + delete(m.menus, name) + delete(m.clickHandlers, name) + m.removed = append(m.removed, name) + return nil +} + +func (m *mockPlatform) Get(name string) (*ContextMenuDef, bool) { + m.mu.Lock() + defer m.mu.Unlock() + menu, ok := m.menus[name] + if !ok { + return nil, false + } + return &menu, true +} + +func (m *mockPlatform) GetAll() map[string]ContextMenuDef { + m.mu.Lock() + defer m.mu.Unlock() + out := make(map[string]ContextMenuDef, len(m.menus)) + for k, v := range m.menus { + out[k] = v + } + return out +} + +// simulateClick simulates a context menu item click by calling the registered handler. +func (m *mockPlatform) simulateClick(menuName, actionID, data string) { + m.mu.Lock() + h, ok := m.clickHandlers[menuName] + m.mu.Unlock() + if ok { + h(menuName, actionID, data) + } +} + +func newTestContextMenuService(t *testing.T, mp *mockPlatform) (*Service, *core.Core) { + t.Helper() + c, err := core.New( + core.WithService(Register(mp)), + core.WithServiceLock(), + ) + require.NoError(t, err) + require.NoError(t, c.ServiceStartup(context.Background(), nil)) + svc := core.MustServiceFor[*Service](c, "contextmenu") + return svc, c +} + +func TestRegister_Good(t *testing.T) { + mp := newMockPlatform() + svc, _ := newTestContextMenuService(t, mp) + assert.NotNil(t, svc) + assert.NotNil(t, svc.platform) +} + +func TestTaskAdd_Good(t *testing.T) { + mp := newMockPlatform() + _, c := newTestContextMenuService(t, mp) + + _, handled, err := c.PERFORM(TaskAdd{ + Name: "file-menu", + Menu: ContextMenuDef{ + Name: "file-menu", + Items: []MenuItemDef{ + {Label: "Open", ActionID: "open"}, + {Label: "Delete", ActionID: "delete"}, + }, + }, + }) + require.NoError(t, err) + assert.True(t, handled) + + // Verify menu registered on platform + _, ok := mp.Get("file-menu") + assert.True(t, ok) +} + +func TestTaskAdd_Good_ReplaceExisting(t *testing.T) { + mp := newMockPlatform() + _, c := newTestContextMenuService(t, mp) + + // Add initial menu + _, _, _ = c.PERFORM(TaskAdd{ + Name: "ctx", + Menu: ContextMenuDef{Name: "ctx", Items: []MenuItemDef{{Label: "A", ActionID: "a"}}}, + }) + + // Replace with new menu + _, handled, err := c.PERFORM(TaskAdd{ + Name: "ctx", + Menu: ContextMenuDef{Name: "ctx", Items: []MenuItemDef{{Label: "B", ActionID: "b"}}}, + }) + require.NoError(t, err) + assert.True(t, handled) + + // Verify registry has new menu + result, _, _ := c.QUERY(QueryGet{Name: "ctx"}) + def := result.(*ContextMenuDef) + require.Len(t, def.Items, 1) + assert.Equal(t, "B", def.Items[0].Label) +} + +func TestTaskRemove_Good(t *testing.T) { + mp := newMockPlatform() + _, c := newTestContextMenuService(t, mp) + + // Add then remove + _, _, _ = c.PERFORM(TaskAdd{ + Name: "test", + Menu: ContextMenuDef{Name: "test"}, + }) + _, handled, err := c.PERFORM(TaskRemove{Name: "test"}) + require.NoError(t, err) + assert.True(t, handled) + + // Verify removed from registry + result, _, _ := c.QUERY(QueryGet{Name: "test"}) + assert.Nil(t, result) +} + +func TestTaskRemove_Bad_NotFound(t *testing.T) { + mp := newMockPlatform() + _, c := newTestContextMenuService(t, mp) + + _, handled, err := c.PERFORM(TaskRemove{Name: "nonexistent"}) + assert.True(t, handled) + assert.ErrorIs(t, err, ErrMenuNotFound) +} + +func TestQueryGet_Good(t *testing.T) { + mp := newMockPlatform() + _, c := newTestContextMenuService(t, mp) + + _, _, _ = c.PERFORM(TaskAdd{ + Name: "my-menu", + Menu: ContextMenuDef{ + Name: "my-menu", + Items: []MenuItemDef{{Label: "Edit", ActionID: "edit"}}, + }, + }) + + result, handled, err := c.QUERY(QueryGet{Name: "my-menu"}) + require.NoError(t, err) + assert.True(t, handled) + def := result.(*ContextMenuDef) + assert.Equal(t, "my-menu", def.Name) + assert.Len(t, def.Items, 1) +} + +func TestQueryGet_Good_NotFound(t *testing.T) { + mp := newMockPlatform() + _, c := newTestContextMenuService(t, mp) + + result, handled, err := c.QUERY(QueryGet{Name: "missing"}) + require.NoError(t, err) + assert.True(t, handled) + assert.Nil(t, result) +} + +func TestQueryList_Good(t *testing.T) { + mp := newMockPlatform() + _, c := newTestContextMenuService(t, mp) + + _, _, _ = c.PERFORM(TaskAdd{Name: "a", Menu: ContextMenuDef{Name: "a"}}) + _, _, _ = c.PERFORM(TaskAdd{Name: "b", Menu: ContextMenuDef{Name: "b"}}) + + result, handled, err := c.QUERY(QueryList{}) + require.NoError(t, err) + assert.True(t, handled) + list := result.(map[string]ContextMenuDef) + assert.Len(t, list, 2) +} + +func TestQueryList_Good_Empty(t *testing.T) { + mp := newMockPlatform() + _, c := newTestContextMenuService(t, mp) + + result, handled, err := c.QUERY(QueryList{}) + require.NoError(t, err) + assert.True(t, handled) + list := result.(map[string]ContextMenuDef) + assert.Len(t, list, 0) +} + +func TestTaskAdd_Good_ClickBroadcast(t *testing.T) { + mp := newMockPlatform() + _, c := newTestContextMenuService(t, mp) + + // Capture broadcast actions + var clicked ActionItemClicked + var mu sync.Mutex + c.RegisterAction(func(_ *core.Core, msg core.Message) error { + if a, ok := msg.(ActionItemClicked); ok { + mu.Lock() + clicked = a + mu.Unlock() + } + return nil + }) + + _, _, _ = c.PERFORM(TaskAdd{ + Name: "file-menu", + Menu: ContextMenuDef{ + Name: "file-menu", + Items: []MenuItemDef{ + {Label: "Open", ActionID: "open"}, + }, + }, + }) + + // Simulate click via mock + mp.simulateClick("file-menu", "open", "file-123") + + mu.Lock() + assert.Equal(t, "file-menu", clicked.MenuName) + assert.Equal(t, "open", clicked.ActionID) + assert.Equal(t, "file-123", clicked.Data) + mu.Unlock() +} + +func TestTaskAdd_Good_SubmenuItems(t *testing.T) { + mp := newMockPlatform() + _, c := newTestContextMenuService(t, mp) + + _, handled, err := c.PERFORM(TaskAdd{ + Name: "nested", + Menu: ContextMenuDef{ + Name: "nested", + Items: []MenuItemDef{ + {Label: "File", Type: "submenu", Items: []MenuItemDef{ + {Label: "New", ActionID: "new"}, + {Label: "Open", ActionID: "open"}, + }}, + {Type: "separator"}, + {Label: "Quit", ActionID: "quit"}, + }, + }, + }) + require.NoError(t, err) + assert.True(t, handled) + + result, _, _ := c.QUERY(QueryGet{Name: "nested"}) + def := result.(*ContextMenuDef) + assert.Len(t, def.Items, 3) + assert.Len(t, def.Items[0].Items, 2) // submenu children +} + +func TestQueryList_Bad_NoService(t *testing.T) { + c, _ := core.New(core.WithServiceLock()) + _, handled, _ := c.QUERY(QueryList{}) + assert.False(t, handled) +}