refactor(display): remove extracted clipboard/dialog/notification/theme/screen code
Some checks failed
Security Scan / security (push) Failing after 9s
Test / test (push) Failing after 1m33s

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-03-13 14:24:06 +00:00
parent 335d93d05c
commit bba743d2cb
10 changed files with 17 additions and 670 deletions

View file

@ -1,61 +0,0 @@
package display
import (
"fmt"
"github.com/wailsapp/wails/v3/pkg/application"
)
// ClipboardContentType represents the type of content in the clipboard.
type ClipboardContentType string
const (
ClipboardText ClipboardContentType = "text"
ClipboardImage ClipboardContentType = "image"
ClipboardHTML ClipboardContentType = "html"
)
// ClipboardContent holds clipboard data.
type ClipboardContent struct {
Type ClipboardContentType `json:"type"`
Text string `json:"text,omitempty"`
HTML string `json:"html,omitempty"`
}
// ReadClipboard reads text content from the system clipboard.
func (s *Service) ReadClipboard() (string, error) {
app := application.Get()
if app == nil || app.Clipboard == nil {
return "", fmt.Errorf("application or clipboard not available")
}
text, ok := app.Clipboard.Text()
if !ok {
return "", fmt.Errorf("failed to read clipboard")
}
return text, nil
}
// WriteClipboard writes text content to the system clipboard.
func (s *Service) WriteClipboard(text string) error {
app := application.Get()
if app == nil || app.Clipboard == nil {
return fmt.Errorf("application or clipboard not available")
}
if !app.Clipboard.SetText(text) {
return fmt.Errorf("failed to write to clipboard")
}
return nil
}
// HasClipboard checks if the clipboard has content.
func (s *Service) HasClipboard() bool {
text, err := s.ReadClipboard()
return err == nil && text != ""
}
// ClearClipboard clears the clipboard by setting empty text.
func (s *Service) ClearClipboard() error {
return s.WriteClipboard("")
}

View file

@ -1,192 +0,0 @@
package display
import (
"fmt"
"github.com/wailsapp/wails/v3/pkg/application"
)
// FileFilter represents a file type filter for dialogs.
type FileFilter struct {
DisplayName string `json:"displayName"`
Pattern string `json:"pattern"`
Extensions []string `json:"extensions,omitempty"`
}
// OpenFileOptions contains options for the open file dialog.
type OpenFileOptions struct {
Title string `json:"title,omitempty"`
DefaultDirectory string `json:"defaultDirectory,omitempty"`
DefaultFilename string `json:"defaultFilename,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"`
DefaultDirectory string `json:"defaultDirectory,omitempty"`
DefaultFilename string `json:"defaultFilename,omitempty"`
Filters []FileFilter `json:"filters,omitempty"`
}
// OpenDirectoryOptions contains options for the directory picker.
type OpenDirectoryOptions struct {
Title string `json:"title,omitempty"`
DefaultDirectory string `json:"defaultDirectory,omitempty"`
AllowMultiple bool `json:"allowMultiple,omitempty"`
}
// OpenFileDialog shows a file open dialog and returns selected path(s).
func (s *Service) OpenFileDialog(opts OpenFileOptions) ([]string, error) {
app := application.Get()
if app == nil {
return nil, fmt.Errorf("application not available")
}
dialog := app.Dialog.OpenFile()
if opts.Title != "" {
dialog.SetTitle(opts.Title)
}
if opts.DefaultDirectory != "" {
dialog.SetDirectory(opts.DefaultDirectory)
}
// Add filters
for _, f := range opts.Filters {
dialog.AddFilter(f.DisplayName, f.Pattern)
}
if opts.AllowMultiple {
dialog.CanChooseFiles(true)
// Use PromptForMultipleSelection for multiple files
paths, err := dialog.PromptForMultipleSelection()
if err != nil {
return nil, fmt.Errorf("dialog error: %w", err)
}
return paths, nil
}
// Single selection
path, err := dialog.PromptForSingleSelection()
if err != nil {
return nil, fmt.Errorf("dialog error: %w", err)
}
if path == "" {
return []string{}, nil
}
return []string{path}, nil
}
// OpenSingleFileDialog shows a file open dialog for a single file.
func (s *Service) OpenSingleFileDialog(opts OpenFileOptions) (string, error) {
app := application.Get()
if app == nil {
return "", fmt.Errorf("application not available")
}
dialog := app.Dialog.OpenFile()
if opts.Title != "" {
dialog.SetTitle(opts.Title)
}
if opts.DefaultDirectory != "" {
dialog.SetDirectory(opts.DefaultDirectory)
}
for _, f := range opts.Filters {
dialog.AddFilter(f.DisplayName, f.Pattern)
}
path, err := dialog.PromptForSingleSelection()
if err != nil {
return "", fmt.Errorf("dialog error: %w", err)
}
return path, nil
}
// SaveFileDialog shows a save file dialog and returns the selected path.
func (s *Service) SaveFileDialog(opts SaveFileOptions) (string, error) {
app := application.Get()
if app == nil {
return "", fmt.Errorf("application not available")
}
dialog := app.Dialog.SaveFile()
if opts.DefaultDirectory != "" {
dialog.SetDirectory(opts.DefaultDirectory)
}
if opts.DefaultFilename != "" {
dialog.SetFilename(opts.DefaultFilename)
}
for _, f := range opts.Filters {
dialog.AddFilter(f.DisplayName, f.Pattern)
}
path, err := dialog.PromptForSingleSelection()
if err != nil {
return "", fmt.Errorf("dialog error: %w", err)
}
return path, nil
}
// OpenDirectoryDialog shows a directory picker.
func (s *Service) OpenDirectoryDialog(opts OpenDirectoryOptions) (string, error) {
app := application.Get()
if app == nil {
return "", fmt.Errorf("application not available")
}
// Use OpenFile dialog with directory selection
dialog := app.Dialog.OpenFile()
dialog.CanChooseDirectories(true)
dialog.CanChooseFiles(false)
if opts.Title != "" {
dialog.SetTitle(opts.Title)
}
if opts.DefaultDirectory != "" {
dialog.SetDirectory(opts.DefaultDirectory)
}
path, err := dialog.PromptForSingleSelection()
if err != nil {
return "", fmt.Errorf("dialog error: %w", err)
}
return path, nil
}
// ConfirmDialog shows a confirmation dialog and returns the user's choice.
func (s *Service) ConfirmDialog(title, message string) (bool, error) {
app := application.Get()
if app == nil {
return false, fmt.Errorf("application not available")
}
dialog := app.Dialog.Question()
dialog.SetTitle(title)
dialog.SetMessage(message)
dialog.AddButton("Yes").SetAsDefault()
dialog.AddButton("No")
dialog.Show()
// Note: Wails v3 Question dialog Show() doesn't return a value
// The button callbacks would need to be used for async handling
// For now, return true as we showed the dialog
return true, nil
}
// PromptDialog shows an input prompt dialog.
// Note: Wails v3 doesn't have a native prompt dialog, so this uses a question dialog.
func (s *Service) PromptDialog(title, message string) (string, bool, error) {
// Wails v3 doesn't have a native text input dialog
// For now, return an error suggesting to use webview-based input
return "", false, fmt.Errorf("text input dialogs not supported natively; use webview-based input instead")
}

View file

@ -9,6 +9,7 @@ import (
"forge.lthn.ai/core/go-config"
"forge.lthn.ai/core/go/pkg/core"
"forge.lthn.ai/core/gui/pkg/dialog"
"forge.lthn.ai/core/gui/pkg/environment"
"forge.lthn.ai/core/gui/pkg/menu"
"forge.lthn.ai/core/gui/pkg/notification"
@ -16,7 +17,6 @@ import (
"forge.lthn.ai/core/gui/pkg/systray"
"forge.lthn.ai/core/gui/pkg/window"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/services/notifications"
)
// Options holds configuration for the display service.
@ -35,8 +35,7 @@ type Service struct {
config Options
configData map[string]map[string]any
cfg *config.Config // go-config instance for file persistence
notifier *notifications.NotificationService
events *WSEventManager
events *WSEventManager
}
// New is the constructor for the display service.
@ -77,8 +76,7 @@ func (s *Service) OnStartup(ctx context.Context) error {
// Initialise Wails wrappers if app is available (nil in tests)
if s.wailsApp != nil {
s.app = newWailsApp(s.wailsApp)
s.events = NewWSEventManager(newWailsEventSource(s.wailsApp))
s.events.SetupWindowEventListeners()
s.events = NewWSEventManager()
}
return nil
@ -165,8 +163,18 @@ func (s *Service) handleTrayAction(actionID string) {
case "close-desktop":
// Hide all windows — future: add TaskHideWindow
case "env-info":
if s.app != nil {
s.ShowEnvironmentDialog()
// Query environment info via IPC and show as dialog
result, handled, _ := s.Core().QUERY(environment.QueryInfo{})
if handled {
info := result.(environment.EnvironmentInfo)
details := fmt.Sprintf("OS: %s\nArch: %s\nPlatform: %s %s",
info.OS, info.Arch, info.Platform.Name, info.Platform.Version)
_, _, _ = s.Core().PERFORM(dialog.TaskMessageDialog{
Opts: dialog.MessageDialogOptions{
Type: dialog.DialogInfo, Title: "Environment",
Message: details, Buttons: []string{"OK"},
},
})
}
case "quit":
if s.app != nil {
@ -619,171 +627,6 @@ func (s *Service) ApplyWorkflowLayout(workflow window.WorkflowLayout) error {
return ws.Manager().ApplyWorkflow(workflow, ws.Manager().List(), 1920, 1080)
}
// --- Screen queries (remain in display — use application.Get() directly) ---
// ScreenInfo contains information about a display screen.
type ScreenInfo struct {
ID string `json:"id"`
Name string `json:"name"`
X int `json:"x"`
Y int `json:"y"`
Width int `json:"width"`
Height int `json:"height"`
Primary bool `json:"primary"`
}
// WorkArea represents usable screen space.
type WorkArea struct {
ScreenID string `json:"screenId"`
X int `json:"x"`
Y int `json:"y"`
Width int `json:"width"`
Height int `json:"height"`
}
// GetScreens returns information about all available screens.
func (s *Service) GetScreens() []ScreenInfo {
app := application.Get()
if app == nil || app.Screen == nil {
return nil
}
screens := app.Screen.GetAll()
if screens == nil {
return nil
}
result := make([]ScreenInfo, 0, len(screens))
for _, screen := range screens {
result = append(result, ScreenInfo{
ID: screen.ID,
Name: screen.Name,
X: screen.Bounds.X,
Y: screen.Bounds.Y,
Width: screen.Bounds.Width,
Height: screen.Bounds.Height,
Primary: screen.IsPrimary,
})
}
return result
}
// GetWorkAreas returns the usable work area for all screens.
func (s *Service) GetWorkAreas() []WorkArea {
app := application.Get()
if app == nil || app.Screen == nil {
return nil
}
screens := app.Screen.GetAll()
if screens == nil {
return nil
}
result := make([]WorkArea, 0, len(screens))
for _, screen := range screens {
result = append(result, WorkArea{
ScreenID: screen.ID,
X: screen.WorkArea.X,
Y: screen.WorkArea.Y,
Width: screen.WorkArea.Width,
Height: screen.WorkArea.Height,
})
}
return result
}
// GetPrimaryScreen returns information about the primary screen.
func (s *Service) GetPrimaryScreen() (*ScreenInfo, error) {
app := application.Get()
if app == nil || app.Screen == nil {
return nil, fmt.Errorf("screen service not available")
}
screens := app.Screen.GetAll()
for _, screen := range screens {
if screen.IsPrimary {
return &ScreenInfo{
ID: screen.ID, Name: screen.Name,
X: screen.Bounds.X, Y: screen.Bounds.Y,
Width: screen.Bounds.Width, Height: screen.Bounds.Height,
Primary: true,
}, nil
}
}
return nil, fmt.Errorf("no primary screen found")
}
// GetScreen returns information about a specific screen by ID.
func (s *Service) GetScreen(id string) (*ScreenInfo, error) {
app := application.Get()
if app == nil || app.Screen == nil {
return nil, fmt.Errorf("screen service not available")
}
screens := app.Screen.GetAll()
for _, screen := range screens {
if screen.ID == id {
return &ScreenInfo{
ID: screen.ID, Name: screen.Name,
X: screen.Bounds.X, Y: screen.Bounds.Y,
Width: screen.Bounds.Width, Height: screen.Bounds.Height,
Primary: screen.IsPrimary,
}, nil
}
}
return nil, fmt.Errorf("screen not found: %s", id)
}
// GetScreenAtPoint returns the screen containing a specific point.
func (s *Service) GetScreenAtPoint(x, y int) (*ScreenInfo, error) {
app := application.Get()
if app == nil || app.Screen == nil {
return nil, fmt.Errorf("screen service not available")
}
screens := app.Screen.GetAll()
for _, screen := range screens {
bounds := screen.Bounds
if x >= bounds.X && x < bounds.X+bounds.Width &&
y >= bounds.Y && y < bounds.Y+bounds.Height {
return &ScreenInfo{
ID: screen.ID, Name: screen.Name,
X: bounds.X, Y: bounds.Y,
Width: bounds.Width, Height: bounds.Height,
Primary: screen.IsPrimary,
}, nil
}
}
return nil, fmt.Errorf("no screen found at point (%d, %d)", x, y)
}
// GetScreenForWindow returns the screen containing a specific window.
func (s *Service) GetScreenForWindow(name string) (*ScreenInfo, error) {
info, err := s.GetWindowInfo(name)
if err != nil {
return nil, err
}
centerX := info.X + info.Width/2
centerY := info.Y + info.Height/2
return s.GetScreenAtPoint(centerX, centerY)
}
// ShowEnvironmentDialog displays environment information.
func (s *Service) ShowEnvironmentDialog() {
envInfo := s.app.Env().Info()
details := "Environment Information:\n\n"
details += fmt.Sprintf("Operating System: %s\n", envInfo.OS)
details += fmt.Sprintf("Architecture: %s\n", envInfo.Arch)
details += fmt.Sprintf("Debug Mode: %t\n\n", envInfo.Debug)
details += fmt.Sprintf("Dark Mode: %t\n\n", s.app.Env().IsDarkMode())
details += "Platform Information:"
for key, value := range envInfo.PlatformInfo {
details += fmt.Sprintf("\n%s: %v", key, value)
}
if envInfo.OSInfo != nil {
details += fmt.Sprintf("\n\nOS Details:\nName: %s\nVersion: %s",
envInfo.OSInfo.Name, envInfo.OSInfo.Version)
}
dialog := s.app.Dialog().Info()
dialog.SetTitle("Environment Information")
dialog.SetMessage(details)
dialog.Show()
}
// GetEventManager returns the event manager for WebSocket event subscriptions.
func (s *Service) GetEventManager() *WSEventManager {
return s.events

View file

@ -432,25 +432,13 @@ func TestHandleListWorkspaces_Good(t *testing.T) {
}
func TestWSEventManager_Good(t *testing.T) {
es := newMockEventSource()
em := NewWSEventManager(es)
em := NewWSEventManager()
defer em.Close()
assert.NotNil(t, em)
assert.Equal(t, 0, em.ConnectedClients())
}
func TestWSEventManager_SetupWindowEventListeners_Good(t *testing.T) {
es := newMockEventSource()
em := NewWSEventManager(es)
defer em.Close()
em.SetupWindowEventListeners()
// Verify theme handler was registered
assert.Len(t, es.themeHandlers, 1)
}
// --- Config file loading tests ---
func TestLoadConfig_Good(t *testing.T) {

View file

@ -47,7 +47,6 @@ type WSEventManager struct {
upgrader websocket.Upgrader
clients map[*websocket.Conn]*clientState
mu sync.RWMutex
eventSource EventSource
nextSubID int
eventBuffer chan Event
}
@ -59,8 +58,7 @@ type clientState struct {
}
// NewWSEventManager creates a new event manager.
// It accepts an EventSource for theme change events instead of using application.Get() directly.
func NewWSEventManager(es EventSource) *WSEventManager {
func NewWSEventManager() *WSEventManager {
em := &WSEventManager{
upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
@ -70,7 +68,6 @@ func NewWSEventManager(es EventSource) *WSEventManager {
WriteBufferSize: 1024,
},
clients: make(map[*websocket.Conn]*clientState),
eventSource: es,
eventBuffer: make(chan Event, 100),
}
@ -305,26 +302,6 @@ func (em *WSEventManager) Close() {
close(em.eventBuffer)
}
// SetupWindowEventListeners registers listeners for application-level events.
// Uses EventSource instead of application.Get() directly.
func (em *WSEventManager) SetupWindowEventListeners() {
if em.eventSource != nil {
em.eventSource.OnThemeChange(func(isDark bool) {
theme := "light"
if isDark {
theme = "dark"
}
em.Emit(Event{
Type: EventThemeChange,
Data: map[string]any{
"isDark": isDark,
"theme": theme,
},
})
})
}
}
// AttachWindowListeners attaches event listeners to a specific window.
// Accepts window.PlatformWindow instead of *application.WebviewWindow.
func (em *WSEventManager) AttachWindowListeners(pw window.PlatformWindow) {

View file

@ -76,19 +76,3 @@ func (ev *wailsEventManager) Emit(name string, data ...any) bool {
return ev.app.Event.Emit(name, data...)
}
// wailsEventSource implements EventSource using a Wails app.
type wailsEventSource struct{ app *application.App }
func newWailsEventSource(app *application.App) EventSource {
return &wailsEventSource{app: app}
}
func (es *wailsEventSource) OnThemeChange(handler func(isDark bool)) func() {
return es.app.Event.OnApplicationEvent(events.Common.ThemeChanged, func(_ *application.ApplicationEvent) {
handler(es.app.Env.IsDarkMode())
})
}
func (es *wailsEventSource) Emit(name string, data ...any) bool {
return es.app.Event.Emit(name, data...)
}

View file

@ -1,19 +1 @@
package display
// mockEventSource implements EventSource for testing.
type mockEventSource struct {
themeHandlers []func(isDark bool)
}
func newMockEventSource() *mockEventSource {
return &mockEventSource{}
}
func (m *mockEventSource) OnThemeChange(handler func(isDark bool)) func() {
m.themeHandlers = append(m.themeHandlers, handler)
return func() {}
}
func (m *mockEventSource) Emit(name string, data ...any) bool {
return true
}

View file

@ -1,127 +0,0 @@
package display
import (
"fmt"
"time"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/services/notifications"
)
// NotificationOptions contains options for showing a notification.
type NotificationOptions struct {
ID string `json:"id,omitempty"`
Title string `json:"title"`
Message string `json:"message"`
Subtitle string `json:"subtitle,omitempty"`
}
// SetNotifier sets the notifications service for native notifications.
func (s *Service) SetNotifier(notifier *notifications.NotificationService) {
s.notifier = notifier
}
// ShowNotification displays a native system notification.
// Falls back to dialog if notifier is not available.
func (s *Service) ShowNotification(opts NotificationOptions) error {
// Try native notification first
if s.notifier != nil {
// Generate ID if not provided
id := opts.ID
if id == "" {
id = fmt.Sprintf("core-%d", time.Now().UnixNano())
}
return s.notifier.SendNotification(notifications.NotificationOptions{
ID: id,
Title: opts.Title,
Subtitle: opts.Subtitle,
Body: opts.Message,
})
}
// Fall back to dialog-based notification
return s.showDialogNotification(opts)
}
// showDialogNotification shows a notification using dialogs as fallback.
func (s *Service) showDialogNotification(opts NotificationOptions) error {
app := application.Get()
if app == nil {
return fmt.Errorf("application not available")
}
// Build message with optional subtitle
msg := opts.Message
if opts.Subtitle != "" {
msg = opts.Subtitle + "\n\n" + msg
}
dialog := app.Dialog.Info()
dialog.SetTitle(opts.Title)
dialog.SetMessage(msg)
dialog.Show()
return nil
}
// ShowInfoNotification shows an info notification with a simple message.
func (s *Service) ShowInfoNotification(title, message string) error {
return s.ShowNotification(NotificationOptions{
Title: title,
Message: message,
})
}
// ShowWarningNotification shows a warning notification.
func (s *Service) ShowWarningNotification(title, message string) error {
app := application.Get()
if app == nil {
return fmt.Errorf("application not available")
}
dialog := app.Dialog.Warning()
dialog.SetTitle(title)
dialog.SetMessage(message)
dialog.Show()
return nil
}
// ShowErrorNotification shows an error notification.
func (s *Service) ShowErrorNotification(title, message string) error {
app := application.Get()
if app == nil {
return fmt.Errorf("application not available")
}
dialog := app.Dialog.Error()
dialog.SetTitle(title)
dialog.SetMessage(message)
dialog.Show()
return nil
}
// RequestNotificationPermission requests permission for native notifications.
func (s *Service) RequestNotificationPermission() (bool, error) {
if s.notifier == nil {
return false, fmt.Errorf("notification service not available")
}
granted, err := s.notifier.RequestNotificationAuthorization()
if err != nil {
return false, fmt.Errorf("failed to request notification permission: %w", err)
}
return granted, nil
}
// CheckNotificationPermission checks if notifications are authorized.
func (s *Service) CheckNotificationPermission() (bool, error) {
if s.notifier == nil {
return false, fmt.Errorf("notification service not available")
}
return s.notifier.CheckNotificationAuthorization()
}

View file

@ -1,38 +0,0 @@
package display
import (
"github.com/wailsapp/wails/v3/pkg/application"
)
// ThemeInfo contains information about the current theme.
type ThemeInfo struct {
IsDark bool `json:"isDark"`
Theme string `json:"theme"` // "dark" or "light"
System bool `json:"system"` // Whether following system theme
}
// GetTheme returns the current application theme.
func (s *Service) GetTheme() ThemeInfo {
app := application.Get()
if app == nil {
return ThemeInfo{Theme: "unknown"}
}
isDark := app.Env.IsDarkMode()
theme := "light"
if isDark {
theme = "dark"
}
return ThemeInfo{
IsDark: isDark,
Theme: theme,
System: true, // Wails follows system theme by default
}
}
// GetSystemTheme returns the system's theme preference.
// This is the same as GetTheme since Wails follows the system theme.
func (s *Service) GetSystemTheme() ThemeInfo {
return s.GetTheme()
}

View file

@ -1,9 +0,0 @@
// pkg/display/types.go
package display
// EventSource abstracts the application event system (Wails insulation for WSEventManager).
// WSEventManager receives this instead of calling application.Get() directly.
type EventSource interface {
OnThemeChange(handler func(isDark bool)) func()
Emit(name string, data ...any) bool
}