diff --git a/pkg/display/display.go b/pkg/display/display.go index 6dedc966..ac42148a 100644 --- a/pkg/display/display.go +++ b/pkg/display/display.go @@ -1790,6 +1790,25 @@ func (s *Service) GetEventManager() *WSEventManager { // --- Menu (handlers stay in display, structure delegated via IPC) --- func (s *Service) buildMenu() { + developerItems := []menu.MenuItem{ + {Label: "New File", Accelerator: "CmdOrCtrl+N", OnClick: s.handleNewFile}, + {Label: "Open File...", Accelerator: "CmdOrCtrl+O", OnClick: s.handleOpenFile}, + {Label: "Save", Accelerator: "CmdOrCtrl+S", OnClick: s.handleSaveFile}, + {Type: "separator"}, + {Label: "Editor", OnClick: s.handleOpenEditor}, + {Label: "Terminal", OnClick: s.handleOpenTerminal}, + {Type: "separator"}, + {Label: "Run", Accelerator: "CmdOrCtrl+R", OnClick: s.handleRun}, + {Label: "Build", Accelerator: "CmdOrCtrl+B", OnClick: s.handleBuild}, + } + if menuService, ok := core.ServiceFor[*menu.Service](s.Core(), "menu"); ok && menuService.ShowDevTools() { + developerItems = append(developerItems, + menu.MenuItem{Type: "separator"}, + menu.MenuItem{Label: "Open DevTools", OnClick: s.handleOpenDevTools}, + menu.MenuItem{Label: "Close DevTools", OnClick: s.handleCloseDevTools}, + ) + } + items := []menu.MenuItem{ {Role: pointerTo(menu.RoleAppMenu)}, {Role: pointerTo(menu.RoleFileMenu)}, @@ -1799,17 +1818,7 @@ func (s *Service) buildMenu() { {Label: "New...", OnClick: s.handleNewWorkspace}, {Label: "List", OnClick: s.handleListWorkspaces}, }}, - {Label: "Developer", Children: []menu.MenuItem{ - {Label: "New File", Accelerator: "CmdOrCtrl+N", OnClick: s.handleNewFile}, - {Label: "Open File...", Accelerator: "CmdOrCtrl+O", OnClick: s.handleOpenFile}, - {Label: "Save", Accelerator: "CmdOrCtrl+S", OnClick: s.handleSaveFile}, - {Type: "separator"}, - {Label: "Editor", OnClick: s.handleOpenEditor}, - {Label: "Terminal", OnClick: s.handleOpenTerminal}, - {Type: "separator"}, - {Label: "Run", Accelerator: "CmdOrCtrl+R", OnClick: s.handleRun}, - {Label: "Build", Accelerator: "CmdOrCtrl+B", OnClick: s.handleBuild}, - }}, + {Label: "Developer", Children: developerItems}, {Role: pointerTo(menu.RoleWindowMenu)}, {Role: pointerTo(menu.RoleHelpMenu)}, } @@ -1829,6 +1838,37 @@ func pointerTo[T any](value T) *T { return &value } // --- Menu handler methods --- +func (s *Service) menuDevToolsWindow() string { + if name := s.GetFocusedWindow(); name != "" { + return name + } + infos := s.ListWindowInfos() + if len(infos) == 1 { + return infos[0].Name + } + return "" +} + +func (s *Service) handleOpenDevTools() { + windowName := s.menuDevToolsWindow() + if windowName == "" { + return + } + _ = s.Core().Action("webview.devtoolsOpen").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: webview.TaskDevToolsOpen{Window: windowName}}, + )) +} + +func (s *Service) handleCloseDevTools() { + windowName := s.menuDevToolsWindow() + if windowName == "" { + return + } + _ = s.Core().Action("webview.devtoolsClose").Run(context.Background(), core.NewOptions( + core.Option{Key: "task", Value: webview.TaskDevToolsClose{Window: windowName}}, + )) +} + func (s *Service) handleNewWorkspace() { _ = s.Core().Action("window.open").Run(context.Background(), core.NewOptions( core.Option{Key: "task", Value: window.TaskOpenWindow{ diff --git a/pkg/display/display_test.go b/pkg/display/display_test.go index 59c03550..abe4bdd9 100644 --- a/pkg/display/display_test.go +++ b/pkg/display/display_test.go @@ -14,6 +14,7 @@ import ( core "dappco.re/go/core" "forge.lthn.ai/core/gui/pkg/menu" "forge.lthn.ai/core/gui/pkg/systray" + "forge.lthn.ai/core/gui/pkg/webview" "forge.lthn.ai/core/gui/pkg/window" "github.com/gorilla/websocket" "github.com/stretchr/testify/assert" @@ -54,6 +55,144 @@ func taskRun(c *core.Core, name string, task any) core.Result { )) } +func registerDisplayWithConfigPath(path string) func(*core.Core) core.Result { + return func(c *core.Core) core.Result { + result := Register(nil)(c) + if !result.OK { + return result + } + svc := core.MustServiceFor[*Service](c, "display") + svc.loadConfigFrom(path) + return result + } +} + +func writeMenuConfig(t *testing.T, showDevTools bool) string { + t.Helper() + + dir := t.TempDir() + cfgPath := core.JoinPath(dir, ".core", "gui", "config.yaml") + require.NoError(t, os.MkdirAll(core.PathDir(cfgPath), 0o755)) + require.NoError(t, os.WriteFile(cfgPath, []byte(` +menu: + show_dev_tools: `+map[bool]string{true: "true", false: "false"}[showDevTools]+` +`), 0o644)) + return cfgPath +} + +func newDevToolsMenuConclave(t *testing.T, showDevTools bool) (*core.Core, *captureMenuPlatform, *window.MockPlatform) { + t.Helper() + + menuPlatform := newCaptureMenuPlatform() + windowPlatform := window.NewMockPlatform() + c := core.New( + core.WithService(registerDisplayWithConfigPath(writeMenuConfig(t, showDevTools))), + core.WithService(window.Register(windowPlatform)), + core.WithService(systray.Register(systray.NewMockPlatform())), + core.WithService(webview.Register()), + core.WithService(menu.Register(menuPlatform)), + core.WithServiceLock(), + ) + require.True(t, c.ServiceStartup(context.Background(), nil).OK) + return c, menuPlatform, windowPlatform +} + +type captureMenuPlatform struct { + appMenu *captureMenu +} + +func newCaptureMenuPlatform() *captureMenuPlatform { + return &captureMenuPlatform{} +} + +func (p *captureMenuPlatform) NewMenu() menu.PlatformMenu { + return &captureMenu{} +} + +func (p *captureMenuPlatform) SetApplicationMenu(menuHandle menu.PlatformMenu) { + captured, _ := menuHandle.(*captureMenu) + p.appMenu = captured +} + +type captureMenu struct { + items []*captureMenuItem + roles []menu.MenuRole +} + +func (m *captureMenu) Add(label string) menu.PlatformMenuItem { + item := &captureMenuItem{label: label} + m.items = append(m.items, item) + return item +} + +func (m *captureMenu) AddSeparator() { + m.items = append(m.items, &captureMenuItem{label: "---", separator: true}) +} + +func (m *captureMenu) AddSubmenu(label string) menu.PlatformMenu { + sub := &captureMenu{} + m.items = append(m.items, &captureMenuItem{label: label, submenu: sub}) + return sub +} + +func (m *captureMenu) AddRole(role menu.MenuRole) { + m.roles = append(m.roles, role) +} + +func (m *captureMenu) findSubmenu(label string) *captureMenu { + for _, item := range m.items { + if item.label == label && item.submenu != nil { + return item.submenu + } + } + return nil +} + +func (m *captureMenu) findItem(label string) *captureMenuItem { + for _, item := range m.items { + if item.label == label && item.submenu == nil && !item.separator { + return item + } + } + return nil +} + +type captureMenuItem struct { + label string + accelerator string + tooltip string + checked bool + enabled bool + separator bool + submenu *captureMenu + onClick func() +} + +func (m *captureMenuItem) SetAccelerator(accel string) menu.PlatformMenuItem { + m.accelerator = accel + return m +} + +func (m *captureMenuItem) SetTooltip(text string) menu.PlatformMenuItem { + m.tooltip = text + return m +} + +func (m *captureMenuItem) SetChecked(checked bool) menu.PlatformMenuItem { + m.checked = checked + return m +} + +func (m *captureMenuItem) SetEnabled(enabled bool) menu.PlatformMenuItem { + m.enabled = enabled + return m +} + +func (m *captureMenuItem) OnClick(fn func()) menu.PlatformMenuItem { + m.onClick = fn + return m +} + // --- Tests --- func TestNew_Good(t *testing.T) { @@ -192,6 +331,43 @@ func TestServiceConclave_Bad(t *testing.T) { assert.False(t, r.OK, "no display service means no config handler") } +func TestBuildMenu_Good_ShowDevTools(t *testing.T) { + c, menuPlatform, windowPlatform := newDevToolsMenuConclave(t, true) + + require.True(t, taskRun(c, "window.open", window.TaskOpenWindow{ + Window: &window.Window{Name: "main"}, + }).OK) + require.True(t, taskRun(c, "window.focus", window.TaskFocus{Name: "main"}).OK) + + require.NotNil(t, menuPlatform.appMenu) + developer := menuPlatform.appMenu.findSubmenu("Developer") + require.NotNil(t, developer) + + openItem := developer.findItem("Open DevTools") + closeItem := developer.findItem("Close DevTools") + require.NotNil(t, openItem) + require.NotNil(t, closeItem) + require.NotNil(t, openItem.onClick) + require.NotNil(t, closeItem.onClick) + require.Len(t, windowPlatform.Windows, 1) + + openItem.onClick() + assert.True(t, windowPlatform.Windows[0].DevToolsOpen()) + + closeItem.onClick() + assert.False(t, windowPlatform.Windows[0].DevToolsOpen()) +} + +func TestBuildMenu_Bad_ShowDevToolsDisabled(t *testing.T) { + _, menuPlatform, _ := newDevToolsMenuConclave(t, false) + + require.NotNil(t, menuPlatform.appMenu) + developer := menuPlatform.appMenu.findSubmenu("Developer") + require.NotNil(t, developer) + assert.Nil(t, developer.findItem("Open DevTools")) + assert.Nil(t, developer.findItem("Close DevTools")) +} + // --- IPC delegation tests (full conclave) --- func TestOpenWindow_Good(t *testing.T) { diff --git a/pkg/mcp/tools_webview_test.go b/pkg/mcp/tools_webview_test.go new file mode 100644 index 00000000..3d05ab95 --- /dev/null +++ b/pkg/mcp/tools_webview_test.go @@ -0,0 +1,89 @@ +package mcp + +import ( + "context" + "errors" + "testing" + + core "dappco.re/go/core" + "forge.lthn.ai/core/gui/pkg/webview" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newWebviewToolsTestSubsystem(t *testing.T, handler func(name string, opts core.Options) core.Result) *Subsystem { + t.Helper() + + c := core.New(core.WithServiceLock()) + c.Action("webview.devtoolsOpen", func(_ context.Context, opts core.Options) core.Result { + if handler != nil { + return handler("webview.devtoolsOpen", opts) + } + return core.Result{} + }) + c.Action("webview.devtoolsClose", func(_ context.Context, opts core.Options) core.Result { + if handler != nil { + return handler("webview.devtoolsClose", opts) + } + return core.Result{} + }) + return New(c) +} + +func TestToolsWebview_webviewDevTools_Good(t *testing.T) { + var calls []string + + sub := newWebviewToolsTestSubsystem(t, func(name string, opts core.Options) core.Result { + calls = append(calls, name) + switch task := opts.Get("task").Value.(type) { + case webview.TaskDevToolsOpen: + assert.Equal(t, "main", task.Window) + case webview.TaskDevToolsClose: + assert.Equal(t, "main", task.Window) + default: + t.Fatalf("unexpected task type %T", task) + } + return core.Result{OK: true} + }) + + server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.1.0"}, nil) + sub.registerWebviewTools(server) + + result, err := sub.CallTool(context.Background(), "webview_devtools_open", map[string]any{"window": "main"}) + require.NoError(t, err) + assert.Contains(t, result, "\"success\":true") + + result, err = sub.CallTool(context.Background(), "webview_devtools_close", map[string]any{"window": "main"}) + require.NoError(t, err) + assert.Contains(t, result, "\"success\":true") + assert.Equal(t, []string{"webview.devtoolsOpen", "webview.devtoolsClose"}, calls) +} + +func TestToolsWebview_webviewDevToolsOpen_Bad(t *testing.T) { + sub := newWebviewToolsTestSubsystem(t, func(name string, opts core.Options) core.Result { + task, ok := opts.Get("task").Value.(webview.TaskDevToolsOpen) + require.True(t, ok) + assert.Equal(t, "main", task.Window) + assert.Equal(t, "webview.devtoolsOpen", name) + return core.Result{Value: errors.New("devtools unavailable"), OK: false} + }) + + _, _, err := sub.webviewDevToolsOpen(context.Background(), nil, WebviewDevToolsOpenInput{Window: "main"}) + require.Error(t, err) + assert.Equal(t, "devtools unavailable", err.Error()) +} + +func TestToolsWebview_webviewDevToolsClose_Ugly(t *testing.T) { + sub := newWebviewToolsTestSubsystem(t, func(name string, opts core.Options) core.Result { + task, ok := opts.Get("task").Value.(webview.TaskDevToolsClose) + require.True(t, ok) + assert.Equal(t, "main", task.Window) + assert.Equal(t, "webview.devtoolsClose", name) + return core.Result{Value: "suppressed failure", OK: false} + }) + + _, out, err := sub.webviewDevToolsClose(context.Background(), nil, WebviewDevToolsCloseInput{Window: "main"}) + require.NoError(t, err) + assert.False(t, out.Success) +}