feat(dialog): add dialog core.Service with Platform interface and IPC

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-03-13 14:17:35 +00:00
parent 7b8ed9df6f
commit df37dfa041
4 changed files with 253 additions and 0 deletions

14
pkg/dialog/messages.go Normal file
View file

@ -0,0 +1,14 @@
// pkg/dialog/messages.go
package dialog
// TaskOpenFile shows an open file dialog. Result: []string (paths)
type TaskOpenFile struct{ Opts OpenFileOptions }
// TaskSaveFile shows a save file dialog. Result: string (path)
type TaskSaveFile struct{ Opts SaveFileOptions }
// TaskOpenDirectory shows a directory picker. Result: string (path)
type TaskOpenDirectory struct{ Opts OpenDirectoryOptions }
// TaskMessageDialog shows a message dialog. Result: string (button clicked)
type TaskMessageDialog struct{ Opts MessageDialogOptions }

59
pkg/dialog/platform.go Normal file
View file

@ -0,0 +1,59 @@
// pkg/dialog/platform.go
package dialog
// Platform abstracts the native dialog backend.
type Platform interface {
OpenFile(opts OpenFileOptions) ([]string, error)
SaveFile(opts SaveFileOptions) (string, error)
OpenDirectory(opts OpenDirectoryOptions) (string, error)
MessageDialog(opts MessageDialogOptions) (string, error)
}
// DialogType represents the type of message dialog.
type DialogType int
const (
DialogInfo DialogType = iota
DialogWarning
DialogError
DialogQuestion
)
// OpenFileOptions contains options for the open file dialog.
type OpenFileOptions struct {
Title string `json:"title,omitempty"`
Directory string `json:"directory,omitempty"`
Filename string `json:"filename,omitempty"`
Filters []FileFilter `json:"filters,omitempty"`
AllowMultiple bool `json:"allowMultiple,omitempty"`
}
// SaveFileOptions contains options for the save file dialog.
type SaveFileOptions struct {
Title string `json:"title,omitempty"`
Directory string `json:"directory,omitempty"`
Filename string `json:"filename,omitempty"`
Filters []FileFilter `json:"filters,omitempty"`
}
// OpenDirectoryOptions contains options for the directory picker.
type OpenDirectoryOptions struct {
Title string `json:"title,omitempty"`
Directory string `json:"directory,omitempty"`
AllowMultiple bool `json:"allowMultiple,omitempty"`
}
// MessageDialogOptions contains options for a message dialog.
type MessageDialogOptions struct {
Type DialogType `json:"type"`
Title string `json:"title"`
Message string `json:"message"`
Buttons []string `json:"buttons,omitempty"`
}
// FileFilter represents a file type filter for dialogs.
type FileFilter struct {
DisplayName string `json:"displayName"`
Pattern string `json:"pattern"`
Extensions []string `json:"extensions,omitempty"`
}

57
pkg/dialog/service.go Normal file
View file

@ -0,0 +1,57 @@
// pkg/dialog/service.go
package dialog
import (
"context"
"forge.lthn.ai/core/go/pkg/core"
)
// Options holds configuration for the dialog service.
type Options struct{}
// Service is a core.Service managing native dialogs via IPC.
type Service struct {
*core.ServiceRuntime[Options]
platform Platform
}
// Register creates a factory closure that captures the Platform adapter.
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,
}, nil
}
}
// OnStartup registers IPC handlers.
func (s *Service) OnStartup(ctx context.Context) error {
s.Core().RegisterTask(s.handleTask)
return nil
}
// HandleIPCEvents is auto-discovered by core.WithService.
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
}
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
switch t := t.(type) {
case TaskOpenFile:
paths, err := s.platform.OpenFile(t.Opts)
return paths, true, err
case TaskSaveFile:
path, err := s.platform.SaveFile(t.Opts)
return path, true, err
case TaskOpenDirectory:
path, err := s.platform.OpenDirectory(t.Opts)
return path, true, err
case TaskMessageDialog:
button, err := s.platform.MessageDialog(t.Opts)
return button, true, err
default:
return nil, false, nil
}
}

123
pkg/dialog/service_test.go Normal file
View file

@ -0,0 +1,123 @@
// pkg/dialog/service_test.go
package dialog
import (
"context"
"testing"
"forge.lthn.ai/core/go/pkg/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type mockPlatform struct {
openFilePaths []string
saveFilePath string
openDirPath string
messageButton string
openFileErr error
saveFileErr error
openDirErr error
messageErr error
lastOpenOpts OpenFileOptions
lastSaveOpts SaveFileOptions
lastDirOpts OpenDirectoryOptions
lastMsgOpts MessageDialogOptions
}
func (m *mockPlatform) OpenFile(opts OpenFileOptions) ([]string, error) {
m.lastOpenOpts = opts
return m.openFilePaths, m.openFileErr
}
func (m *mockPlatform) SaveFile(opts SaveFileOptions) (string, error) {
m.lastSaveOpts = opts
return m.saveFilePath, m.saveFileErr
}
func (m *mockPlatform) OpenDirectory(opts OpenDirectoryOptions) (string, error) {
m.lastDirOpts = opts
return m.openDirPath, m.openDirErr
}
func (m *mockPlatform) MessageDialog(opts MessageDialogOptions) (string, error) {
m.lastMsgOpts = opts
return m.messageButton, m.messageErr
}
func newTestService(t *testing.T) (*mockPlatform, *core.Core) {
t.Helper()
mock := &mockPlatform{
openFilePaths: []string{"/tmp/file.txt"},
saveFilePath: "/tmp/save.txt",
openDirPath: "/tmp/dir",
messageButton: "OK",
}
c, err := core.New(
core.WithService(Register(mock)),
core.WithServiceLock(),
)
require.NoError(t, err)
require.NoError(t, c.ServiceStartup(context.Background(), nil))
return mock, c
}
func TestRegister_Good(t *testing.T) {
_, c := newTestService(t)
svc := core.MustServiceFor[*Service](c, "dialog")
assert.NotNil(t, svc)
}
func TestTaskOpenFile_Good(t *testing.T) {
mock, c := newTestService(t)
mock.openFilePaths = []string{"/a.txt", "/b.txt"}
result, handled, err := c.PERFORM(TaskOpenFile{
Opts: OpenFileOptions{Title: "Pick", AllowMultiple: true},
})
require.NoError(t, err)
assert.True(t, handled)
paths := result.([]string)
assert.Equal(t, []string{"/a.txt", "/b.txt"}, paths)
assert.Equal(t, "Pick", mock.lastOpenOpts.Title)
assert.True(t, mock.lastOpenOpts.AllowMultiple)
}
func TestTaskSaveFile_Good(t *testing.T) {
_, c := newTestService(t)
result, handled, err := c.PERFORM(TaskSaveFile{
Opts: SaveFileOptions{Filename: "out.txt"},
})
require.NoError(t, err)
assert.True(t, handled)
assert.Equal(t, "/tmp/save.txt", result)
}
func TestTaskOpenDirectory_Good(t *testing.T) {
_, c := newTestService(t)
result, handled, err := c.PERFORM(TaskOpenDirectory{
Opts: OpenDirectoryOptions{Title: "Pick Dir"},
})
require.NoError(t, err)
assert.True(t, handled)
assert.Equal(t, "/tmp/dir", result)
}
func TestTaskMessageDialog_Good(t *testing.T) {
mock, c := newTestService(t)
mock.messageButton = "Yes"
result, handled, err := c.PERFORM(TaskMessageDialog{
Opts: MessageDialogOptions{
Type: DialogQuestion, Title: "Confirm",
Message: "Sure?", Buttons: []string{"Yes", "No"},
},
})
require.NoError(t, err)
assert.True(t, handled)
assert.Equal(t, "Yes", result)
assert.Equal(t, DialogQuestion, mock.lastMsgOpts.Type)
}
func TestTaskOpenFile_Bad(t *testing.T) {
c, _ := core.New(core.WithServiceLock())
_, handled, _ := c.PERFORM(TaskOpenFile{})
assert.False(t, handled)
}