feat(gui): webview devtools open/close MCP tools
display.go: menu builder now consults menu.Service.ShowDevTools() and, when enabled, adds Developer > Open DevTools + Close DevTools menu items. Handlers route to the focused window (or the only open window) via the existing webview.devtoolsOpen / webview.devtoolsClose actions — which already land on Wails WebviewWindow.OpenDevTools / CloseDevTools. Tests: Good/Bad/Ugly coverage for both MCP tool handlers in tools_webview_test.go plus a menu-level test in display_test.go that clicks the built items and verifies mock devtools state flips open/closed, with a disabled-config case. tools_webview.go already had the handlers + registration on this branch; locked down with tests instead of re-implementing. Closes tasks.lthn.sh/view.php?id=31 Co-authored-by: Codex <noreply@openai.com> Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
fa4168e380
commit
dfff893f16
3 changed files with 316 additions and 11 deletions
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
89
pkg/mcp/tools_webview_test.go
Normal file
89
pkg/mcp/tools_webview_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue