diff --git a/CLAUDE.md b/CLAUDE.md index 594d4c0..402eb18 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,38 +38,43 @@ The display `Service` registers with `forge.lthn.ai/core/go`'s service container All Wails application APIs are abstracted behind interfaces in `interfaces.go` (`App`, `WindowManager`, `MenuManager`, `DialogManager`, etc.). The `wailsApp` adapter wraps the real Wails app. Tests inject a `mockApp` instead — see `mocks_test.go` and the `newServiceWithMockApp(t)` helper. -### Key files in pkg/display/ +### Package structure (pkg/) -| File | Responsibility | -|------|---------------| -| `display.go` | Service struct, lifecycle (`Startup`), window CRUD, screen queries, tiling/snapping/layout, workflow presets | -| `window.go` | `WindowOption` functional options pattern, `Window` type alias for `application.WebviewWindowOptions` | -| `window_state.go` | `WindowStateManager` — persists window position/size across restarts | -| `layout.go` | `LayoutManager` — save/restore named window arrangements | -| `events.go` | `WSEventManager` — WebSocket pub/sub for window/theme/screen events | -| `interfaces.go` | Abstract interfaces + Wails adapter implementations | -| `actions.go` | `ActionOpenWindow` IPC message type | -| `menu.go` | Application menu construction | -| `tray.go` | System tray setup | -| `dialog.go` | File/directory dialogs | -| `clipboard.go` | Clipboard read/write | -| `notification.go` | System notifications | -| `theme.go` | Dark/light mode detection | -| `mocks_test.go` | Mock implementations of all interfaces for testing | +| Package | Responsibility | +|---------|---------------| +| `display` | Orchestrator service — bridges sub-service IPC to WebSocket events, menu/tray setup, config persistence | +| `window` | Window lifecycle, `Manager`, `StateManager` (position persistence), `LayoutManager` (named arrangements), tiling/snapping | +| `menu` | Application menu construction via platform abstraction | +| `systray` | System tray icon, tooltip, menu via platform abstraction | +| `dialog` | File open/save, message, confirm, and prompt dialogs | +| `clipboard` | Clipboard read/write/clear | +| `notification` | System notifications with permission management | +| `screen` | Screen/monitor queries (list, primary, at-point, work areas) | +| `environment` | Theme detection (dark/light) and OS environment info | +| `keybinding` | Global keyboard shortcut registration | +| `contextmenu` | Named context menu registration and lifecycle | +| `browser` | Open URLs and files in the default browser | +| `dock` | macOS dock icon visibility and badge | +| `lifecycle` | Application lifecycle events (start, terminate, suspend, resume) | +| `webview` | Webview automation (eval, click, type, screenshot, DOM queries) | +| `mcp` | MCP tool subsystem — exposes all services as Model Context Protocol tools | ### Patterns used throughout -- **Functional options**: `WindowOption` functions (`WindowName()`, `WindowTitle()`, `WindowWidth()`, etc.) configure `application.WebviewWindowOptions` -- **Type alias**: `Window = application.WebviewWindowOptions` — direct alias, not a wrapper +- **Platform abstraction**: Each sub-service defines a `Platform` interface and `PlatformWindow`/`PlatformTray`/etc. types; tests inject mocks +- **Functional options**: `WindowOption` functions (`WithName()`, `WithTitle()`, `WithSize()`, etc.) configure `window.Window` descriptors +- **IPC message bus**: Sub-services communicate via `core.QUERY`, `core.PERFORM`, and `core.ACTION` — display orchestrates and bridges to WebSocket events - **Event broadcasting**: `WSEventManager` uses gorilla/websocket with a buffered channel (`eventBuffer`) and per-client subscription filtering (supports `"*"` wildcard) -- **Window lookup by name**: Most Service methods iterate `s.app.Window().GetAll()` and type-assert to `*application.WebviewWindow`, then match by `Name()` +- **Error handling**: All errors use `coreerr.E(op, msg, err)` from `forge.lthn.ai/core/go-log` (aliased as `coreerr`), never `fmt.Errorf` +- **File I/O**: Use `forge.lthn.ai/core/go-io` (`coreio.Local`) for filesystem operations, never `os.ReadFile`/`os.WriteFile` ## Testing - Framework: `testify` (assert + require) -- Pattern: `newServiceWithMockApp(t)` creates a `Service` with mock Wails app — no real window system needed -- `newTestCore(t)` creates a real `core.Core` instance for integration-style tests -- Some tests use `defer func() { recover() }()` to handle nil panics from mock methods that return nil pointers (e.g., `Dialog().Info()`) +- Each sub-package has its own `*_test.go` with mock platform implementations +- `pkg/window`: `NewManagerWithDir` / `NewStateManagerWithDir` / `NewLayoutManagerWithDir` accept custom config dirs for isolated tests +- `pkg/display`: `newTestCore(t)` creates a real `core.Core` instance for integration-style tests +- Sub-services use `mock_platform.go` or `mock_test.go` for platform mocks ## CI/CD @@ -82,9 +87,13 @@ Both use reusable workflows from `core/go-devops`. ## Dependencies - `forge.lthn.ai/core/go` — Core framework with service container and DI +- `forge.lthn.ai/core/go-log` — Structured errors (`coreerr.E()`) +- `forge.lthn.ai/core/go-io` — Filesystem abstraction (`coreio.Local`) +- `forge.lthn.ai/core/config` — Configuration file management - `github.com/wailsapp/wails/v3` — Desktop app framework (alpha.74) - `github.com/gorilla/websocket` — WebSocket for real-time events - `github.com/stretchr/testify` — Test assertions +- `github.com/modelcontextprotocol/go-sdk` — MCP tool registration ## Repository migration note diff --git a/go.mod b/go.mod index 653c918..227fed1 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,8 @@ go 1.26.0 require ( forge.lthn.ai/core/config v0.1.6 forge.lthn.ai/core/go v0.3.1 + forge.lthn.ai/core/go-io v0.1.5 + forge.lthn.ai/core/go-log v0.0.4 forge.lthn.ai/core/go-webview v0.1.5 github.com/gorilla/websocket v1.5.3 github.com/leaanthony/u v1.1.1 @@ -16,8 +18,6 @@ require ( require ( dario.cat/mergo v1.0.2 // indirect - forge.lthn.ai/core/go-io v0.1.5 // indirect - forge.lthn.ai/core/go-log v0.0.4 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.4.0 // indirect github.com/adrg/xdg v0.5.3 // indirect diff --git a/pkg/contextmenu/service.go b/pkg/contextmenu/service.go index 973346d..44ef16b 100644 --- a/pkg/contextmenu/service.go +++ b/pkg/contextmenu/service.go @@ -3,8 +3,8 @@ package contextmenu import ( "context" - "fmt" + coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/go/pkg/core" ) @@ -92,7 +92,7 @@ func (s *Service) taskAdd(t TaskAdd) error { }) }) if err != nil { - return fmt.Errorf("contextmenu: platform add failed: %w", err) + return coreerr.E("contextmenu.taskAdd", "platform add failed", err) } s.menus[t.Name] = t.Menu @@ -106,7 +106,7 @@ func (s *Service) taskRemove(t TaskRemove) error { err := s.platform.Remove(t.Name) if err != nil { - return fmt.Errorf("contextmenu: platform remove failed: %w", err) + return coreerr.E("contextmenu.taskRemove", "platform remove failed", err) } delete(s.menus, t.Name) diff --git a/pkg/display/display.go b/pkg/display/display.go index 9b6b77a..96e031d 100644 --- a/pkg/display/display.go +++ b/pkg/display/display.go @@ -9,6 +9,7 @@ import ( "forge.lthn.ai/core/config" "forge.lthn.ai/core/go/pkg/core" + coreerr "forge.lthn.ai/core/go-log" "encoding/json" "forge.lthn.ai/core/gui/pkg/browser" @@ -241,7 +242,7 @@ type WSMessage struct { func wsRequire(data map[string]any, key string) (string, error) { v, _ := data[key].(string) if v == "" { - return "", fmt.Errorf("ws: missing required field %q", key) + return "", coreerr.E("display.wsRequire", fmt.Sprintf("missing required field %q", key), nil) } return v, nil } @@ -600,7 +601,7 @@ func (s *Service) GetWindowInfo(name string) (*window.WindowInfo, error) { return nil, err } if !handled { - return nil, fmt.Errorf("window service not available") + return nil, coreerr.E("display.GetWindowInfo", "window service not available", nil) } info, _ := result.(*window.WindowInfo) return info, nil @@ -666,11 +667,11 @@ func (s *Service) CloseWindow(name string) error { func (s *Service) RestoreWindow(name string) error { ws := s.windowService() if ws == nil { - return fmt.Errorf("window service not available") + return coreerr.E("display.RestoreWindow", "window service not available", nil) } pw, ok := ws.Manager().Get(name) if !ok { - return fmt.Errorf("window not found: %s", name) + return coreerr.E("display.RestoreWindow", "window not found: "+name, nil) } pw.Restore() return nil @@ -681,11 +682,11 @@ func (s *Service) RestoreWindow(name string) error { func (s *Service) SetWindowVisibility(name string, visible bool) error { ws := s.windowService() if ws == nil { - return fmt.Errorf("window service not available") + return coreerr.E("display.SetWindowVisibility", "window service not available", nil) } pw, ok := ws.Manager().Get(name) if !ok { - return fmt.Errorf("window not found: %s", name) + return coreerr.E("display.SetWindowVisibility", "window not found: "+name, nil) } pw.SetVisibility(visible) return nil @@ -696,11 +697,11 @@ func (s *Service) SetWindowVisibility(name string, visible bool) error { func (s *Service) SetWindowAlwaysOnTop(name string, alwaysOnTop bool) error { ws := s.windowService() if ws == nil { - return fmt.Errorf("window service not available") + return coreerr.E("display.SetWindowAlwaysOnTop", "window service not available", nil) } pw, ok := ws.Manager().Get(name) if !ok { - return fmt.Errorf("window not found: %s", name) + return coreerr.E("display.SetWindowAlwaysOnTop", "window not found: "+name, nil) } pw.SetAlwaysOnTop(alwaysOnTop) return nil @@ -711,11 +712,11 @@ func (s *Service) SetWindowAlwaysOnTop(name string, alwaysOnTop bool) error { func (s *Service) SetWindowTitle(name string, title string) error { ws := s.windowService() if ws == nil { - return fmt.Errorf("window service not available") + return coreerr.E("display.SetWindowTitle", "window service not available", nil) } pw, ok := ws.Manager().Get(name) if !ok { - return fmt.Errorf("window not found: %s", name) + return coreerr.E("display.SetWindowTitle", "window not found: "+name, nil) } pw.SetTitle(title) return nil @@ -726,11 +727,11 @@ func (s *Service) SetWindowTitle(name string, title string) error { func (s *Service) SetWindowFullscreen(name string, fullscreen bool) error { ws := s.windowService() if ws == nil { - return fmt.Errorf("window service not available") + return coreerr.E("display.SetWindowFullscreen", "window service not available", nil) } pw, ok := ws.Manager().Get(name) if !ok { - return fmt.Errorf("window not found: %s", name) + return coreerr.E("display.SetWindowFullscreen", "window not found: "+name, nil) } if fullscreen { pw.Fullscreen() @@ -745,11 +746,11 @@ func (s *Service) SetWindowFullscreen(name string, fullscreen bool) error { func (s *Service) SetWindowBackgroundColour(name string, r, g, b, a uint8) error { ws := s.windowService() if ws == nil { - return fmt.Errorf("window service not available") + return coreerr.E("display.SetWindowBackgroundColour", "window service not available", nil) } pw, ok := ws.Manager().Get(name) if !ok { - return fmt.Errorf("window not found: %s", name) + return coreerr.E("display.SetWindowBackgroundColour", "window not found: "+name, nil) } pw.SetBackgroundColour(r, g, b, a) return nil @@ -773,7 +774,7 @@ func (s *Service) GetWindowTitle(name string) (string, error) { return "", err } if info == nil { - return "", fmt.Errorf("window not found: %s", name) + return "", coreerr.E("display.GetWindowTitle", "window not found: "+name, nil) } return info.Title, nil } @@ -816,7 +817,7 @@ type CreateWindowOptions struct { // CreateWindow creates a new window with the specified options. func (s *Service) CreateWindow(opts CreateWindowOptions) (*window.WindowInfo, error) { if opts.Name == "" { - return nil, fmt.Errorf("window name is required") + return nil, coreerr.E("display.CreateWindow", "window name is required", nil) } result, _, err := s.Core().PERFORM(window.TaskOpenWindow{ Opts: []window.WindowOption{ @@ -840,7 +841,7 @@ func (s *Service) CreateWindow(opts CreateWindowOptions) (*window.WindowInfo, er func (s *Service) SaveLayout(name string) error { ws := s.windowService() if ws == nil { - return fmt.Errorf("window service not available") + return coreerr.E("display.SaveLayout", "window service not available", nil) } states := make(map[string]window.WindowState) for _, n := range ws.Manager().List() { @@ -857,11 +858,11 @@ func (s *Service) SaveLayout(name string) error { func (s *Service) RestoreLayout(name string) error { ws := s.windowService() if ws == nil { - return fmt.Errorf("window service not available") + return coreerr.E("display.RestoreLayout", "window service not available", nil) } layout, ok := ws.Manager().Layout().GetLayout(name) if !ok { - return fmt.Errorf("layout not found: %s", name) + return coreerr.E("display.RestoreLayout", "layout not found: "+name, nil) } for wName, state := range layout.Windows { if pw, ok := ws.Manager().Get(wName); ok { @@ -890,7 +891,7 @@ func (s *Service) ListLayouts() []window.LayoutInfo { func (s *Service) DeleteLayout(name string) error { ws := s.windowService() if ws == nil { - return fmt.Errorf("window service not available") + return coreerr.E("display.DeleteLayout", "window service not available", nil) } ws.Manager().Layout().DeleteLayout(name) return nil @@ -915,7 +916,7 @@ func (s *Service) GetLayout(name string) *window.Layout { func (s *Service) TileWindows(mode window.TileMode, windowNames []string) error { ws := s.windowService() if ws == nil { - return fmt.Errorf("window service not available") + return coreerr.E("display.TileWindows", "window service not available", nil) } return ws.Manager().TileWindows(mode, windowNames, 1920, 1080) // TODO: use actual screen size } @@ -924,7 +925,7 @@ func (s *Service) TileWindows(mode window.TileMode, windowNames []string) error func (s *Service) SnapWindow(name string, position window.SnapPosition) error { ws := s.windowService() if ws == nil { - return fmt.Errorf("window service not available") + return coreerr.E("display.SnapWindow", "window service not available", nil) } return ws.Manager().SnapWindow(name, position, 1920, 1080) // TODO: use actual screen size } @@ -933,7 +934,7 @@ func (s *Service) SnapWindow(name string, position window.SnapPosition) error { func (s *Service) StackWindows(windowNames []string, offsetX, offsetY int) error { ws := s.windowService() if ws == nil { - return fmt.Errorf("window service not available") + return coreerr.E("display.StackWindows", "window service not available", nil) } return ws.Manager().StackWindows(windowNames, offsetX, offsetY) } @@ -942,7 +943,7 @@ func (s *Service) StackWindows(windowNames []string, offsetX, offsetY int) error func (s *Service) ApplyWorkflowLayout(workflow window.WorkflowLayout) error { ws := s.windowService() if ws == nil { - return fmt.Errorf("window service not available") + return coreerr.E("display.ApplyWorkflowLayout", "window service not available", nil) } return ws.Manager().ApplyWorkflow(workflow, ws.Manager().List(), 1920, 1080) } diff --git a/pkg/keybinding/service.go b/pkg/keybinding/service.go index 048c259..8461292 100644 --- a/pkg/keybinding/service.go +++ b/pkg/keybinding/service.go @@ -3,8 +3,8 @@ package keybinding import ( "context" - "fmt" + coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/go/pkg/core" ) @@ -75,7 +75,7 @@ func (s *Service) taskAdd(t TaskAdd) error { _ = s.Core().ACTION(ActionTriggered{Accelerator: t.Accelerator}) }) if err != nil { - return fmt.Errorf("keybinding: platform add failed: %w", err) + return coreerr.E("keybinding.taskAdd", "platform add failed", err) } s.bindings[t.Accelerator] = BindingInfo{ @@ -87,12 +87,12 @@ func (s *Service) taskAdd(t TaskAdd) error { func (s *Service) taskRemove(t TaskRemove) error { if _, exists := s.bindings[t.Accelerator]; !exists { - return fmt.Errorf("keybinding: not registered: %s", t.Accelerator) + return coreerr.E("keybinding.taskRemove", "not registered: "+t.Accelerator, nil) } err := s.platform.Remove(t.Accelerator) if err != nil { - return fmt.Errorf("keybinding: platform remove failed: %w", err) + return coreerr.E("keybinding.taskRemove", "platform remove failed", err) } delete(s.bindings, t.Accelerator) diff --git a/pkg/mcp/tools_clipboard.go b/pkg/mcp/tools_clipboard.go index 827586a..629d46a 100644 --- a/pkg/mcp/tools_clipboard.go +++ b/pkg/mcp/tools_clipboard.go @@ -3,9 +3,9 @@ package mcp import ( "context" - "fmt" "forge.lthn.ai/core/gui/pkg/clipboard" + coreerr "forge.lthn.ai/core/go-log" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -23,7 +23,7 @@ func (s *Subsystem) clipboardRead(_ context.Context, _ *mcp.CallToolRequest, _ C } content, ok := result.(clipboard.ClipboardContent) if !ok { - return nil, ClipboardReadOutput{}, fmt.Errorf("unexpected result type from clipboard read query") + return nil, ClipboardReadOutput{}, coreerr.E("mcp.clipboardRead", "unexpected result type", nil) } return nil, ClipboardReadOutput{Content: content.Text}, nil } @@ -44,7 +44,7 @@ func (s *Subsystem) clipboardWrite(_ context.Context, _ *mcp.CallToolRequest, in } success, ok := result.(bool) if !ok { - return nil, ClipboardWriteOutput{}, fmt.Errorf("unexpected result type from clipboard write task") + return nil, ClipboardWriteOutput{}, coreerr.E("mcp.clipboardWrite", "unexpected result type", nil) } return nil, ClipboardWriteOutput{Success: success}, nil } @@ -63,7 +63,7 @@ func (s *Subsystem) clipboardHas(_ context.Context, _ *mcp.CallToolRequest, _ Cl } content, ok := result.(clipboard.ClipboardContent) if !ok { - return nil, ClipboardHasOutput{}, fmt.Errorf("unexpected result type from clipboard has query") + return nil, ClipboardHasOutput{}, coreerr.E("mcp.clipboardHas", "unexpected result type", nil) } return nil, ClipboardHasOutput{HasContent: content.HasContent}, nil } @@ -82,7 +82,7 @@ func (s *Subsystem) clipboardClear(_ context.Context, _ *mcp.CallToolRequest, _ } success, ok := result.(bool) if !ok { - return nil, ClipboardClearOutput{}, fmt.Errorf("unexpected result type from clipboard clear task") + return nil, ClipboardClearOutput{}, coreerr.E("mcp.clipboardClear", "unexpected result type", nil) } return nil, ClipboardClearOutput{Success: success}, nil } diff --git a/pkg/mcp/tools_contextmenu.go b/pkg/mcp/tools_contextmenu.go index 74e9f8b..12eed8f 100644 --- a/pkg/mcp/tools_contextmenu.go +++ b/pkg/mcp/tools_contextmenu.go @@ -4,9 +4,9 @@ package mcp import ( "context" "encoding/json" - "fmt" "forge.lthn.ai/core/gui/pkg/contextmenu" + coreerr "forge.lthn.ai/core/go-log" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -27,11 +27,11 @@ func (s *Subsystem) contextMenuAdd(_ context.Context, _ *mcp.CallToolRequest, in // Convert map[string]any to ContextMenuDef via JSON round-trip menuJSON, err := json.Marshal(input.Menu) if err != nil { - return nil, ContextMenuAddOutput{}, fmt.Errorf("failed to marshal menu definition: %w", err) + return nil, ContextMenuAddOutput{}, coreerr.E("mcp.contextMenuAdd", "failed to marshal menu definition", err) } var menuDef contextmenu.ContextMenuDef if err := json.Unmarshal(menuJSON, &menuDef); err != nil { - return nil, ContextMenuAddOutput{}, fmt.Errorf("failed to unmarshal menu definition: %w", err) + return nil, ContextMenuAddOutput{}, coreerr.E("mcp.contextMenuAdd", "failed to unmarshal menu definition", err) } _, _, err = s.core.PERFORM(contextmenu.TaskAdd{Name: input.Name, Menu: menuDef}) if err != nil { @@ -73,7 +73,7 @@ func (s *Subsystem) contextMenuGet(_ context.Context, _ *mcp.CallToolRequest, in } menu, ok := result.(*contextmenu.ContextMenuDef) if !ok { - return nil, ContextMenuGetOutput{}, fmt.Errorf("unexpected result type from context menu get query") + return nil, ContextMenuGetOutput{}, coreerr.E("mcp.contextMenuGet", "unexpected result type", nil) } if menu == nil { return nil, ContextMenuGetOutput{}, nil @@ -81,11 +81,11 @@ func (s *Subsystem) contextMenuGet(_ context.Context, _ *mcp.CallToolRequest, in // Convert to map[string]any via JSON round-trip to avoid cyclic type in schema menuJSON, err := json.Marshal(menu) if err != nil { - return nil, ContextMenuGetOutput{}, fmt.Errorf("failed to marshal context menu: %w", err) + return nil, ContextMenuGetOutput{}, coreerr.E("mcp.contextMenuGet", "failed to marshal context menu", err) } var menuMap map[string]any if err := json.Unmarshal(menuJSON, &menuMap); err != nil { - return nil, ContextMenuGetOutput{}, fmt.Errorf("failed to unmarshal context menu: %w", err) + return nil, ContextMenuGetOutput{}, coreerr.E("mcp.contextMenuGet", "failed to unmarshal context menu", err) } return nil, ContextMenuGetOutput{Menu: menuMap}, nil } @@ -104,16 +104,16 @@ func (s *Subsystem) contextMenuList(_ context.Context, _ *mcp.CallToolRequest, _ } menus, ok := result.(map[string]contextmenu.ContextMenuDef) if !ok { - return nil, ContextMenuListOutput{}, fmt.Errorf("unexpected result type from context menu list query") + return nil, ContextMenuListOutput{}, coreerr.E("mcp.contextMenuList", "unexpected result type", nil) } // Convert to map[string]any via JSON round-trip to avoid cyclic type in schema menusJSON, err := json.Marshal(menus) if err != nil { - return nil, ContextMenuListOutput{}, fmt.Errorf("failed to marshal context menus: %w", err) + return nil, ContextMenuListOutput{}, coreerr.E("mcp.contextMenuList", "failed to marshal context menus", err) } var menusMap map[string]any if err := json.Unmarshal(menusJSON, &menusMap); err != nil { - return nil, ContextMenuListOutput{}, fmt.Errorf("failed to unmarshal context menus: %w", err) + return nil, ContextMenuListOutput{}, coreerr.E("mcp.contextMenuList", "failed to unmarshal context menus", err) } return nil, ContextMenuListOutput{Menus: menusMap}, nil } diff --git a/pkg/mcp/tools_dialog.go b/pkg/mcp/tools_dialog.go index 06bdf66..bfc6015 100644 --- a/pkg/mcp/tools_dialog.go +++ b/pkg/mcp/tools_dialog.go @@ -3,9 +3,9 @@ package mcp import ( "context" - "fmt" "forge.lthn.ai/core/gui/pkg/dialog" + coreerr "forge.lthn.ai/core/go-log" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -33,7 +33,7 @@ func (s *Subsystem) dialogOpenFile(_ context.Context, _ *mcp.CallToolRequest, in } paths, ok := result.([]string) if !ok { - return nil, DialogOpenFileOutput{}, fmt.Errorf("unexpected result type from open file dialog") + return nil, DialogOpenFileOutput{}, coreerr.E("mcp.dialogOpenFile", "unexpected result type", nil) } return nil, DialogOpenFileOutput{Paths: paths}, nil } @@ -62,7 +62,7 @@ func (s *Subsystem) dialogSaveFile(_ context.Context, _ *mcp.CallToolRequest, in } path, ok := result.(string) if !ok { - return nil, DialogSaveFileOutput{}, fmt.Errorf("unexpected result type from save file dialog") + return nil, DialogSaveFileOutput{}, coreerr.E("mcp.dialogSaveFile", "unexpected result type", nil) } return nil, DialogSaveFileOutput{Path: path}, nil } @@ -87,7 +87,7 @@ func (s *Subsystem) dialogOpenDirectory(_ context.Context, _ *mcp.CallToolReques } path, ok := result.(string) if !ok { - return nil, DialogOpenDirectoryOutput{}, fmt.Errorf("unexpected result type from open directory dialog") + return nil, DialogOpenDirectoryOutput{}, coreerr.E("mcp.dialogOpenDirectory", "unexpected result type", nil) } return nil, DialogOpenDirectoryOutput{Path: path}, nil } @@ -115,7 +115,7 @@ func (s *Subsystem) dialogConfirm(_ context.Context, _ *mcp.CallToolRequest, inp } button, ok := result.(string) if !ok { - return nil, DialogConfirmOutput{}, fmt.Errorf("unexpected result type from confirm dialog") + return nil, DialogConfirmOutput{}, coreerr.E("mcp.dialogConfirm", "unexpected result type", nil) } return nil, DialogConfirmOutput{Button: button}, nil } @@ -142,7 +142,7 @@ func (s *Subsystem) dialogPrompt(_ context.Context, _ *mcp.CallToolRequest, inpu } button, ok := result.(string) if !ok { - return nil, DialogPromptOutput{}, fmt.Errorf("unexpected result type from prompt dialog") + return nil, DialogPromptOutput{}, coreerr.E("mcp.dialogPrompt", "unexpected result type", nil) } return nil, DialogPromptOutput{Button: button}, nil } diff --git a/pkg/mcp/tools_environment.go b/pkg/mcp/tools_environment.go index 87eb0df..8d8ad49 100644 --- a/pkg/mcp/tools_environment.go +++ b/pkg/mcp/tools_environment.go @@ -3,9 +3,9 @@ package mcp import ( "context" - "fmt" "forge.lthn.ai/core/gui/pkg/environment" + coreerr "forge.lthn.ai/core/go-log" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -23,7 +23,7 @@ func (s *Subsystem) themeGet(_ context.Context, _ *mcp.CallToolRequest, _ ThemeG } theme, ok := result.(environment.ThemeInfo) if !ok { - return nil, ThemeGetOutput{}, fmt.Errorf("unexpected result type from theme query") + return nil, ThemeGetOutput{}, coreerr.E("mcp.themeGet", "unexpected result type", nil) } return nil, ThemeGetOutput{Theme: theme}, nil } @@ -42,7 +42,7 @@ func (s *Subsystem) themeSystem(_ context.Context, _ *mcp.CallToolRequest, _ The } info, ok := result.(environment.EnvironmentInfo) if !ok { - return nil, ThemeSystemOutput{}, fmt.Errorf("unexpected result type from environment info query") + return nil, ThemeSystemOutput{}, coreerr.E("mcp.themeSystem", "unexpected result type", nil) } return nil, ThemeSystemOutput{Info: info}, nil } diff --git a/pkg/mcp/tools_layout.go b/pkg/mcp/tools_layout.go index 24ec8fa..18066d3 100644 --- a/pkg/mcp/tools_layout.go +++ b/pkg/mcp/tools_layout.go @@ -3,8 +3,8 @@ package mcp import ( "context" - "fmt" + coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/gui/pkg/window" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -57,7 +57,7 @@ func (s *Subsystem) layoutList(_ context.Context, _ *mcp.CallToolRequest, _ Layo } layouts, ok := result.([]window.LayoutInfo) if !ok { - return nil, LayoutListOutput{}, fmt.Errorf("unexpected result type from layout list query") + return nil, LayoutListOutput{}, coreerr.E("mcp.layoutList", "unexpected result type", nil) } return nil, LayoutListOutput{Layouts: layouts}, nil } @@ -95,7 +95,7 @@ func (s *Subsystem) layoutGet(_ context.Context, _ *mcp.CallToolRequest, input L } layout, ok := result.(*window.Layout) if !ok { - return nil, LayoutGetOutput{}, fmt.Errorf("unexpected result type from layout get query") + return nil, LayoutGetOutput{}, coreerr.E("mcp.layoutGet", "unexpected result type", nil) } return nil, LayoutGetOutput{Layout: layout}, nil } diff --git a/pkg/mcp/tools_notification.go b/pkg/mcp/tools_notification.go index 259e59f..0629054 100644 --- a/pkg/mcp/tools_notification.go +++ b/pkg/mcp/tools_notification.go @@ -3,8 +3,8 @@ package mcp import ( "context" - "fmt" + coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/gui/pkg/notification" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -46,7 +46,7 @@ func (s *Subsystem) notificationPermissionRequest(_ context.Context, _ *mcp.Call } granted, ok := result.(bool) if !ok { - return nil, NotificationPermissionRequestOutput{}, fmt.Errorf("unexpected result type from notification permission request") + return nil, NotificationPermissionRequestOutput{}, coreerr.E("mcp.notificationPermissionRequest", "unexpected result type", nil) } return nil, NotificationPermissionRequestOutput{Granted: granted}, nil } @@ -65,7 +65,7 @@ func (s *Subsystem) notificationPermissionCheck(_ context.Context, _ *mcp.CallTo } status, ok := result.(notification.PermissionStatus) if !ok { - return nil, NotificationPermissionCheckOutput{}, fmt.Errorf("unexpected result type from notification permission check") + return nil, NotificationPermissionCheckOutput{}, coreerr.E("mcp.notificationPermissionCheck", "unexpected result type", nil) } return nil, NotificationPermissionCheckOutput{Granted: status.Granted}, nil } diff --git a/pkg/mcp/tools_screen.go b/pkg/mcp/tools_screen.go index 8a276b9..a89e879 100644 --- a/pkg/mcp/tools_screen.go +++ b/pkg/mcp/tools_screen.go @@ -3,8 +3,8 @@ package mcp import ( "context" - "fmt" + coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/gui/pkg/screen" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -23,7 +23,7 @@ func (s *Subsystem) screenList(_ context.Context, _ *mcp.CallToolRequest, _ Scre } screens, ok := result.([]screen.Screen) if !ok { - return nil, ScreenListOutput{}, fmt.Errorf("unexpected result type from screen list query") + return nil, ScreenListOutput{}, coreerr.E("mcp.screenList", "unexpected result type", nil) } return nil, ScreenListOutput{Screens: screens}, nil } @@ -44,7 +44,7 @@ func (s *Subsystem) screenGet(_ context.Context, _ *mcp.CallToolRequest, input S } scr, ok := result.(*screen.Screen) if !ok { - return nil, ScreenGetOutput{}, fmt.Errorf("unexpected result type from screen get query") + return nil, ScreenGetOutput{}, coreerr.E("mcp.screenGet", "unexpected result type", nil) } return nil, ScreenGetOutput{Screen: scr}, nil } @@ -63,7 +63,7 @@ func (s *Subsystem) screenPrimary(_ context.Context, _ *mcp.CallToolRequest, _ S } scr, ok := result.(*screen.Screen) if !ok { - return nil, ScreenPrimaryOutput{}, fmt.Errorf("unexpected result type from screen primary query") + return nil, ScreenPrimaryOutput{}, coreerr.E("mcp.screenPrimary", "unexpected result type", nil) } return nil, ScreenPrimaryOutput{Screen: scr}, nil } @@ -85,7 +85,7 @@ func (s *Subsystem) screenAtPoint(_ context.Context, _ *mcp.CallToolRequest, inp } scr, ok := result.(*screen.Screen) if !ok { - return nil, ScreenAtPointOutput{}, fmt.Errorf("unexpected result type from screen at point query") + return nil, ScreenAtPointOutput{}, coreerr.E("mcp.screenAtPoint", "unexpected result type", nil) } return nil, ScreenAtPointOutput{Screen: scr}, nil } @@ -104,7 +104,7 @@ func (s *Subsystem) screenWorkAreas(_ context.Context, _ *mcp.CallToolRequest, _ } areas, ok := result.([]screen.Rect) if !ok { - return nil, ScreenWorkAreasOutput{}, fmt.Errorf("unexpected result type from screen work areas query") + return nil, ScreenWorkAreasOutput{}, coreerr.E("mcp.screenWorkAreas", "unexpected result type", nil) } return nil, ScreenWorkAreasOutput{WorkAreas: areas}, nil } diff --git a/pkg/mcp/tools_tray.go b/pkg/mcp/tools_tray.go index 0cbad22..d5efb45 100644 --- a/pkg/mcp/tools_tray.go +++ b/pkg/mcp/tools_tray.go @@ -3,8 +3,8 @@ package mcp import ( "context" - "fmt" + coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/gui/pkg/systray" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -70,7 +70,7 @@ func (s *Subsystem) trayInfo(_ context.Context, _ *mcp.CallToolRequest, _ TrayIn } config, ok := result.(map[string]any) if !ok { - return nil, TrayInfoOutput{}, fmt.Errorf("unexpected result type from tray config query") + return nil, TrayInfoOutput{}, coreerr.E("mcp.trayInfo", "unexpected result type", nil) } return nil, TrayInfoOutput{Config: config}, nil } diff --git a/pkg/mcp/tools_webview.go b/pkg/mcp/tools_webview.go index b598a4b..923fe36 100644 --- a/pkg/mcp/tools_webview.go +++ b/pkg/mcp/tools_webview.go @@ -3,8 +3,8 @@ package mcp import ( "context" - "fmt" + coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/gui/pkg/webview" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -105,7 +105,7 @@ func (s *Subsystem) webviewScreenshot(_ context.Context, _ *mcp.CallToolRequest, } sr, ok := result.(webview.ScreenshotResult) if !ok { - return nil, WebviewScreenshotOutput{}, fmt.Errorf("unexpected result type from webview screenshot") + return nil, WebviewScreenshotOutput{}, coreerr.E("mcp.webviewScreenshot", "unexpected result type", nil) } return nil, WebviewScreenshotOutput{Base64: sr.Base64, MimeType: sr.MimeType}, nil } @@ -248,7 +248,7 @@ func (s *Subsystem) webviewConsole(_ context.Context, _ *mcp.CallToolRequest, in } msgs, ok := result.([]webview.ConsoleMessage) if !ok { - return nil, WebviewConsoleOutput{}, fmt.Errorf("unexpected result type from webview console query") + return nil, WebviewConsoleOutput{}, coreerr.E("mcp.webviewConsole", "unexpected result type", nil) } return nil, WebviewConsoleOutput{Messages: msgs}, nil } @@ -289,7 +289,7 @@ func (s *Subsystem) webviewQuery(_ context.Context, _ *mcp.CallToolRequest, inpu } el, ok := result.(*webview.ElementInfo) if !ok { - return nil, WebviewQueryOutput{}, fmt.Errorf("unexpected result type from webview query") + return nil, WebviewQueryOutput{}, coreerr.E("mcp.webviewQuery", "unexpected result type", nil) } return nil, WebviewQueryOutput{Element: el}, nil } @@ -312,7 +312,7 @@ func (s *Subsystem) webviewQueryAll(_ context.Context, _ *mcp.CallToolRequest, i } els, ok := result.([]*webview.ElementInfo) if !ok { - return nil, WebviewQueryAllOutput{}, fmt.Errorf("unexpected result type from webview query all") + return nil, WebviewQueryAllOutput{}, coreerr.E("mcp.webviewQueryAll", "unexpected result type", nil) } return nil, WebviewQueryAllOutput{Elements: els}, nil } @@ -335,7 +335,7 @@ func (s *Subsystem) webviewDOMTree(_ context.Context, _ *mcp.CallToolRequest, in } html, ok := result.(string) if !ok { - return nil, WebviewDOMTreeOutput{}, fmt.Errorf("unexpected result type from webview DOM tree query") + return nil, WebviewDOMTreeOutput{}, coreerr.E("mcp.webviewDOMTree", "unexpected result type", nil) } return nil, WebviewDOMTreeOutput{HTML: html}, nil } @@ -357,7 +357,7 @@ func (s *Subsystem) webviewURL(_ context.Context, _ *mcp.CallToolRequest, input } url, ok := result.(string) if !ok { - return nil, WebviewURLOutput{}, fmt.Errorf("unexpected result type from webview URL query") + return nil, WebviewURLOutput{}, coreerr.E("mcp.webviewURL", "unexpected result type", nil) } return nil, WebviewURLOutput{URL: url}, nil } @@ -379,7 +379,7 @@ func (s *Subsystem) webviewTitle(_ context.Context, _ *mcp.CallToolRequest, inpu } title, ok := result.(string) if !ok { - return nil, WebviewTitleOutput{}, fmt.Errorf("unexpected result type from webview title query") + return nil, WebviewTitleOutput{}, coreerr.E("mcp.webviewTitle", "unexpected result type", nil) } return nil, WebviewTitleOutput{Title: title}, nil } diff --git a/pkg/mcp/tools_window.go b/pkg/mcp/tools_window.go index e5ac73f..16484d9 100644 --- a/pkg/mcp/tools_window.go +++ b/pkg/mcp/tools_window.go @@ -3,8 +3,8 @@ package mcp import ( "context" - "fmt" + coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/gui/pkg/window" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -23,7 +23,7 @@ func (s *Subsystem) windowList(_ context.Context, _ *mcp.CallToolRequest, _ Wind } windows, ok := result.([]window.WindowInfo) if !ok { - return nil, WindowListOutput{}, fmt.Errorf("unexpected result type from window list query") + return nil, WindowListOutput{}, coreerr.E("mcp.windowList", "unexpected result type", nil) } return nil, WindowListOutput{Windows: windows}, nil } @@ -44,7 +44,7 @@ func (s *Subsystem) windowGet(_ context.Context, _ *mcp.CallToolRequest, input W } info, ok := result.(*window.WindowInfo) if !ok { - return nil, WindowGetOutput{}, fmt.Errorf("unexpected result type from window get query") + return nil, WindowGetOutput{}, coreerr.E("mcp.windowGet", "unexpected result type", nil) } return nil, WindowGetOutput{Window: info}, nil } @@ -63,7 +63,7 @@ func (s *Subsystem) windowFocused(_ context.Context, _ *mcp.CallToolRequest, _ W } windows, ok := result.([]window.WindowInfo) if !ok { - return nil, WindowFocusedOutput{}, fmt.Errorf("unexpected result type from window list query") + return nil, WindowFocusedOutput{}, coreerr.E("mcp.windowFocused", "unexpected result type", nil) } for _, w := range windows { if w.Focused { @@ -110,7 +110,7 @@ func (s *Subsystem) windowCreate(_ context.Context, _ *mcp.CallToolRequest, inpu } info, ok := result.(window.WindowInfo) if !ok { - return nil, WindowCreateOutput{}, fmt.Errorf("unexpected result type from window create task") + return nil, WindowCreateOutput{}, coreerr.E("mcp.windowCreate", "unexpected result type", nil) } return nil, WindowCreateOutput{Window: info}, nil } diff --git a/pkg/systray/menu.go b/pkg/systray/menu.go index 3032a6d..8594f33 100644 --- a/pkg/systray/menu.go +++ b/pkg/systray/menu.go @@ -1,12 +1,12 @@ // pkg/systray/menu.go package systray -import "fmt" +import coreerr "forge.lthn.ai/core/go-log" // SetMenu sets a dynamic menu on the tray from TrayMenuItem descriptors. func (m *Manager) SetMenu(items []TrayMenuItem) error { if m.tray == nil { - return fmt.Errorf("tray not initialised") + return coreerr.E("systray.SetMenu", "tray not initialised", nil) } menu := m.buildMenu(items) m.tray.SetMenu(menu) diff --git a/pkg/systray/tray.go b/pkg/systray/tray.go index 05ffcdf..8d2e108 100644 --- a/pkg/systray/tray.go +++ b/pkg/systray/tray.go @@ -3,8 +3,9 @@ package systray import ( _ "embed" - "fmt" "sync" + + coreerr "forge.lthn.ai/core/go-log" ) //go:embed assets/apptray.png @@ -31,7 +32,7 @@ func NewManager(platform Platform) *Manager { func (m *Manager) Setup(tooltip, label string) error { m.tray = m.platform.NewTray() if m.tray == nil { - return fmt.Errorf("platform returned nil tray") + return coreerr.E("systray.Setup", "platform returned nil tray", nil) } m.tray.SetTemplateIcon(defaultIcon) m.tray.SetTooltip(tooltip) @@ -42,7 +43,7 @@ func (m *Manager) Setup(tooltip, label string) error { // SetIcon sets the tray icon. func (m *Manager) SetIcon(data []byte) error { if m.tray == nil { - return fmt.Errorf("tray not initialised") + return coreerr.E("systray.SetIcon", "tray not initialised", nil) } m.tray.SetIcon(data) return nil @@ -51,7 +52,7 @@ func (m *Manager) SetIcon(data []byte) error { // SetTemplateIcon sets the template icon (macOS). func (m *Manager) SetTemplateIcon(data []byte) error { if m.tray == nil { - return fmt.Errorf("tray not initialised") + return coreerr.E("systray.SetTemplateIcon", "tray not initialised", nil) } m.tray.SetTemplateIcon(data) return nil @@ -60,7 +61,7 @@ func (m *Manager) SetTemplateIcon(data []byte) error { // SetTooltip sets the tray tooltip. func (m *Manager) SetTooltip(text string) error { if m.tray == nil { - return fmt.Errorf("tray not initialised") + return coreerr.E("systray.SetTooltip", "tray not initialised", nil) } m.tray.SetTooltip(text) return nil @@ -69,7 +70,7 @@ func (m *Manager) SetTooltip(text string) error { // SetLabel sets the tray label. func (m *Manager) SetLabel(text string) error { if m.tray == nil { - return fmt.Errorf("tray not initialised") + return coreerr.E("systray.SetLabel", "tray not initialised", nil) } m.tray.SetLabel(text) return nil @@ -78,7 +79,7 @@ func (m *Manager) SetLabel(text string) error { // AttachWindow attaches a panel window to the tray. func (m *Manager) AttachWindow(w WindowHandle) error { if m.tray == nil { - return fmt.Errorf("tray not initialised") + return coreerr.E("systray.AttachWindow", "tray not initialised", nil) } m.tray.AttachWindow(w) return nil diff --git a/pkg/window/layout.go b/pkg/window/layout.go index 545a99f..7021a72 100644 --- a/pkg/window/layout.go +++ b/pkg/window/layout.go @@ -3,11 +3,13 @@ package window import ( "encoding/json" - "fmt" "os" "path/filepath" "sync" "time" + + coreio "forge.lthn.ai/core/go-io" + coreerr "forge.lthn.ai/core/go-log" ) // Layout is a named window arrangement. @@ -65,13 +67,13 @@ func (lm *LayoutManager) load() { if lm.configDir == "" { return } - data, err := os.ReadFile(lm.filePath()) + content, err := coreio.Local.Read(lm.filePath()) if err != nil { return } lm.mu.Lock() defer lm.mu.Unlock() - _ = json.Unmarshal(data, &lm.layouts) + _ = json.Unmarshal([]byte(content), &lm.layouts) } func (lm *LayoutManager) save() { @@ -84,14 +86,14 @@ func (lm *LayoutManager) save() { if err != nil { return } - _ = os.MkdirAll(lm.configDir, 0o755) - _ = os.WriteFile(lm.filePath(), data, 0o644) + _ = coreio.Local.EnsureDir(lm.configDir) + _ = coreio.Local.Write(lm.filePath(), string(data)) } // SaveLayout creates or updates a named layout. func (lm *LayoutManager) SaveLayout(name string, windowStates map[string]WindowState) error { if name == "" { - return fmt.Errorf("layout name cannot be empty") + return coreerr.E("window.LayoutManager.SaveLayout", "layout name cannot be empty", nil) } now := time.Now().UnixMilli() lm.mu.Lock() diff --git a/pkg/window/mock_test.go b/pkg/window/mock_test.go index 72d54ca..8bb08a9 100644 --- a/pkg/window/mock_test.go +++ b/pkg/window/mock_test.go @@ -33,6 +33,8 @@ type mockWindow struct { maximised, focused bool visible, alwaysOnTop bool closed bool + minimised bool + fullscreened bool eventHandlers []func(WindowEvent) fileDropHandlers []func(paths []string, targetID string) } @@ -51,13 +53,13 @@ func (w *mockWindow) SetVisibility(visible bool) { w.visible = visi func (w *mockWindow) SetAlwaysOnTop(alwaysOnTop bool) { w.alwaysOnTop = alwaysOnTop } func (w *mockWindow) Maximise() { w.maximised = true } func (w *mockWindow) Restore() { w.maximised = false } -func (w *mockWindow) Minimise() {} +func (w *mockWindow) Minimise() { w.minimised = true } func (w *mockWindow) Focus() { w.focused = true } func (w *mockWindow) Close() { w.closed = true } func (w *mockWindow) Show() { w.visible = true } func (w *mockWindow) Hide() { w.visible = false } -func (w *mockWindow) Fullscreen() {} -func (w *mockWindow) UnFullscreen() {} +func (w *mockWindow) Fullscreen() { w.fullscreened = true } +func (w *mockWindow) UnFullscreen() { w.fullscreened = false } func (w *mockWindow) OnWindowEvent(handler func(WindowEvent)) { w.eventHandlers = append(w.eventHandlers, handler) } func (w *mockWindow) OnFileDrop(handler func(paths []string, targetID string)) { w.fileDropHandlers = append(w.fileDropHandlers, handler) diff --git a/pkg/window/persistence_test.go b/pkg/window/persistence_test.go new file mode 100644 index 0000000..6a4ee74 --- /dev/null +++ b/pkg/window/persistence_test.go @@ -0,0 +1,318 @@ +// pkg/window/persistence_test.go +package window + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- StateManager Persistence Tests --- + +func TestStateManager_SetAndGet_Good(t *testing.T) { + sm := NewStateManagerWithDir(t.TempDir()) + state := WindowState{ + X: 150, Y: 250, Width: 1024, Height: 768, + Maximized: true, Screen: "primary", URL: "/app", + } + sm.SetState("editor", state) + + got, ok := sm.GetState("editor") + require.True(t, ok) + assert.Equal(t, 150, got.X) + assert.Equal(t, 250, got.Y) + assert.Equal(t, 1024, got.Width) + assert.Equal(t, 768, got.Height) + assert.True(t, got.Maximized) + assert.Equal(t, "primary", got.Screen) + assert.Equal(t, "/app", got.URL) + assert.NotZero(t, got.UpdatedAt, "UpdatedAt should be set by SetState") +} + +func TestStateManager_UpdatePosition_Good(t *testing.T) { + sm := NewStateManagerWithDir(t.TempDir()) + sm.SetState("win", WindowState{X: 0, Y: 0, Width: 800, Height: 600}) + + sm.UpdatePosition("win", 300, 400) + + got, ok := sm.GetState("win") + require.True(t, ok) + assert.Equal(t, 300, got.X) + assert.Equal(t, 400, got.Y) + // Width/Height should remain unchanged + assert.Equal(t, 800, got.Width) + assert.Equal(t, 600, got.Height) +} + +func TestStateManager_UpdateSize_Good(t *testing.T) { + sm := NewStateManagerWithDir(t.TempDir()) + sm.SetState("win", WindowState{X: 100, Y: 200, Width: 800, Height: 600}) + + sm.UpdateSize("win", 1920, 1080) + + got, ok := sm.GetState("win") + require.True(t, ok) + assert.Equal(t, 1920, got.Width) + assert.Equal(t, 1080, got.Height) + // Position should remain unchanged + assert.Equal(t, 100, got.X) + assert.Equal(t, 200, got.Y) +} + +func TestStateManager_UpdateMaximized_Good(t *testing.T) { + sm := NewStateManagerWithDir(t.TempDir()) + sm.SetState("win", WindowState{Width: 800, Height: 600, Maximized: false}) + + sm.UpdateMaximized("win", true) + + got, ok := sm.GetState("win") + require.True(t, ok) + assert.True(t, got.Maximized) + + sm.UpdateMaximized("win", false) + + got, ok = sm.GetState("win") + require.True(t, ok) + assert.False(t, got.Maximized) +} + +func TestStateManager_CaptureState_Good(t *testing.T) { + sm := NewStateManagerWithDir(t.TempDir()) + pw := &mockWindow{ + name: "captured", x: 75, y: 125, + width: 1440, height: 900, maximised: true, + } + + sm.CaptureState(pw) + + got, ok := sm.GetState("captured") + require.True(t, ok) + assert.Equal(t, 75, got.X) + assert.Equal(t, 125, got.Y) + assert.Equal(t, 1440, got.Width) + assert.Equal(t, 900, got.Height) + assert.True(t, got.Maximized) + assert.NotZero(t, got.UpdatedAt) +} + +func TestStateManager_ApplyState_Good(t *testing.T) { + sm := NewStateManagerWithDir(t.TempDir()) + sm.SetState("target", WindowState{X: 55, Y: 65, Width: 700, Height: 500}) + + w := &Window{Name: "target", Width: 1280, Height: 800, X: 0, Y: 0} + sm.ApplyState(w) + + assert.Equal(t, 55, w.X) + assert.Equal(t, 65, w.Y) + assert.Equal(t, 700, w.Width) + assert.Equal(t, 500, w.Height) +} + +func TestStateManager_ApplyState_NoState(t *testing.T) { + sm := NewStateManagerWithDir(t.TempDir()) + + w := &Window{Name: "untouched", Width: 1280, Height: 800, X: 10, Y: 20} + sm.ApplyState(w) + + // Window should remain unchanged when no state is saved + assert.Equal(t, 10, w.X) + assert.Equal(t, 20, w.Y) + assert.Equal(t, 1280, w.Width) + assert.Equal(t, 800, w.Height) +} + +func TestStateManager_ListStates_Good(t *testing.T) { + sm := NewStateManagerWithDir(t.TempDir()) + sm.SetState("alpha", WindowState{Width: 100}) + sm.SetState("beta", WindowState{Width: 200}) + sm.SetState("gamma", WindowState{Width: 300}) + + names := sm.ListStates() + assert.Len(t, names, 3) + assert.Contains(t, names, "alpha") + assert.Contains(t, names, "beta") + assert.Contains(t, names, "gamma") +} + +func TestStateManager_Clear_Good(t *testing.T) { + sm := NewStateManagerWithDir(t.TempDir()) + sm.SetState("a", WindowState{Width: 100}) + sm.SetState("b", WindowState{Width: 200}) + sm.SetState("c", WindowState{Width: 300}) + + sm.Clear() + + names := sm.ListStates() + assert.Empty(t, names) + + _, ok := sm.GetState("a") + assert.False(t, ok) +} + +func TestStateManager_Persistence_Good(t *testing.T) { + dir := t.TempDir() + + // First manager: write state and force sync to disk + sm1 := NewStateManagerWithDir(dir) + sm1.SetState("persist-win", WindowState{ + X: 42, Y: 84, Width: 500, Height: 300, + Maximized: true, Screen: "secondary", URL: "/settings", + }) + sm1.ForceSync() + + // Second manager: load from the same directory + sm2 := NewStateManagerWithDir(dir) + + got, ok := sm2.GetState("persist-win") + require.True(t, ok) + assert.Equal(t, 42, got.X) + assert.Equal(t, 84, got.Y) + assert.Equal(t, 500, got.Width) + assert.Equal(t, 300, got.Height) + assert.True(t, got.Maximized) + assert.Equal(t, "secondary", got.Screen) + assert.Equal(t, "/settings", got.URL) + assert.NotZero(t, got.UpdatedAt) +} + +// --- LayoutManager Persistence Tests --- + +func TestLayoutManager_SaveAndGet_Good(t *testing.T) { + lm := NewLayoutManagerWithDir(t.TempDir()) + windows := map[string]WindowState{ + "editor": {X: 0, Y: 0, Width: 960, Height: 1080}, + "terminal": {X: 960, Y: 0, Width: 960, Height: 540}, + "browser": {X: 960, Y: 540, Width: 960, Height: 540}, + } + + err := lm.SaveLayout("coding", windows) + require.NoError(t, err) + + layout, ok := lm.GetLayout("coding") + require.True(t, ok) + assert.Equal(t, "coding", layout.Name) + assert.Len(t, layout.Windows, 3) + assert.Equal(t, 960, layout.Windows["editor"].Width) + assert.Equal(t, 1080, layout.Windows["editor"].Height) + assert.Equal(t, 960, layout.Windows["terminal"].X) + assert.NotZero(t, layout.CreatedAt) + assert.NotZero(t, layout.UpdatedAt) + assert.Equal(t, layout.CreatedAt, layout.UpdatedAt, "CreatedAt and UpdatedAt should match on first save") +} + +func TestLayoutManager_SaveLayout_EmptyName_Bad(t *testing.T) { + lm := NewLayoutManagerWithDir(t.TempDir()) + err := lm.SaveLayout("", map[string]WindowState{ + "win": {Width: 800}, + }) + assert.Error(t, err) +} + +func TestLayoutManager_SaveLayout_Update_Good(t *testing.T) { + lm := NewLayoutManagerWithDir(t.TempDir()) + + // First save + err := lm.SaveLayout("evolving", map[string]WindowState{ + "win1": {Width: 800, Height: 600}, + }) + require.NoError(t, err) + + first, ok := lm.GetLayout("evolving") + require.True(t, ok) + originalCreatedAt := first.CreatedAt + originalUpdatedAt := first.UpdatedAt + + // Small delay to ensure UpdatedAt differs + time.Sleep(2 * time.Millisecond) + + // Second save with same name but different windows + err = lm.SaveLayout("evolving", map[string]WindowState{ + "win1": {Width: 1024, Height: 768}, + "win2": {Width: 640, Height: 480}, + }) + require.NoError(t, err) + + updated, ok := lm.GetLayout("evolving") + require.True(t, ok) + + // CreatedAt should be preserved from the original save + assert.Equal(t, originalCreatedAt, updated.CreatedAt, "CreatedAt should be preserved on update") + // UpdatedAt should be newer + assert.GreaterOrEqual(t, updated.UpdatedAt, originalUpdatedAt, "UpdatedAt should advance on update") + // Windows should reflect the second save + assert.Len(t, updated.Windows, 2) + assert.Equal(t, 1024, updated.Windows["win1"].Width) +} + +func TestLayoutManager_ListLayouts_Good(t *testing.T) { + lm := NewLayoutManagerWithDir(t.TempDir()) + require.NoError(t, lm.SaveLayout("coding", map[string]WindowState{ + "editor": {Width: 960}, "terminal": {Width: 960}, + })) + require.NoError(t, lm.SaveLayout("presenting", map[string]WindowState{ + "slides": {Width: 1920}, + })) + require.NoError(t, lm.SaveLayout("debugging", map[string]WindowState{ + "code": {Width: 640}, "debugger": {Width: 640}, "console": {Width: 640}, + })) + + infos := lm.ListLayouts() + assert.Len(t, infos, 3) + + // Build a lookup map for assertions regardless of order + byName := make(map[string]LayoutInfo) + for _, info := range infos { + byName[info.Name] = info + } + + assert.Equal(t, 2, byName["coding"].WindowCount) + assert.Equal(t, 1, byName["presenting"].WindowCount) + assert.Equal(t, 3, byName["debugging"].WindowCount) +} + +func TestLayoutManager_DeleteLayout_Good(t *testing.T) { + lm := NewLayoutManagerWithDir(t.TempDir()) + require.NoError(t, lm.SaveLayout("temporary", map[string]WindowState{ + "win": {Width: 800}, + })) + + // Verify it exists + _, ok := lm.GetLayout("temporary") + require.True(t, ok) + + lm.DeleteLayout("temporary") + + // Verify it is gone + _, ok = lm.GetLayout("temporary") + assert.False(t, ok) + + // Verify list is empty + assert.Empty(t, lm.ListLayouts()) +} + +func TestLayoutManager_Persistence_Good(t *testing.T) { + dir := t.TempDir() + + // First manager: save layout to disk + lm1 := NewLayoutManagerWithDir(dir) + err := lm1.SaveLayout("persisted", map[string]WindowState{ + "main": {X: 0, Y: 0, Width: 1280, Height: 800}, + "sidebar": {X: 1280, Y: 0, Width: 640, Height: 800}, + }) + require.NoError(t, err) + + // Second manager: load from the same directory + lm2 := NewLayoutManagerWithDir(dir) + + layout, ok := lm2.GetLayout("persisted") + require.True(t, ok) + assert.Equal(t, "persisted", layout.Name) + assert.Len(t, layout.Windows, 2) + assert.Equal(t, 1280, layout.Windows["main"].Width) + assert.Equal(t, 800, layout.Windows["main"].Height) + assert.Equal(t, 640, layout.Windows["sidebar"].Width) + assert.NotZero(t, layout.CreatedAt) + assert.NotZero(t, layout.UpdatedAt) +} diff --git a/pkg/window/service.go b/pkg/window/service.go index 040ab95..8260e45 100644 --- a/pkg/window/service.go +++ b/pkg/window/service.go @@ -2,8 +2,8 @@ package window import ( "context" - "fmt" + coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/go/pkg/core" ) @@ -207,7 +207,7 @@ func (s *Service) trackWindow(pw PlatformWindow) { func (s *Service) taskCloseWindow(name string) error { pw, ok := s.manager.Get(name) if !ok { - return fmt.Errorf("window not found: %s", name) + return coreerr.E("window.taskClose", "window not found: "+name, nil) } // Persist state BEFORE closing (spec requirement) s.manager.State().CaptureState(pw) @@ -220,7 +220,7 @@ func (s *Service) taskCloseWindow(name string) error { func (s *Service) taskSetPosition(name string, x, y int) error { pw, ok := s.manager.Get(name) if !ok { - return fmt.Errorf("window not found: %s", name) + return coreerr.E("window.taskSetPosition", "window not found: "+name, nil) } pw.SetPosition(x, y) s.manager.State().UpdatePosition(name, x, y) @@ -230,7 +230,7 @@ func (s *Service) taskSetPosition(name string, x, y int) error { func (s *Service) taskSetSize(name string, w, h int) error { pw, ok := s.manager.Get(name) if !ok { - return fmt.Errorf("window not found: %s", name) + return coreerr.E("window.taskSetSize", "window not found: "+name, nil) } pw.SetSize(w, h) s.manager.State().UpdateSize(name, w, h) @@ -240,7 +240,7 @@ func (s *Service) taskSetSize(name string, w, h int) error { func (s *Service) taskMaximise(name string) error { pw, ok := s.manager.Get(name) if !ok { - return fmt.Errorf("window not found: %s", name) + return coreerr.E("window.taskMaximise", "window not found: "+name, nil) } pw.Maximise() s.manager.State().UpdateMaximized(name, true) @@ -250,7 +250,7 @@ func (s *Service) taskMaximise(name string) error { func (s *Service) taskMinimise(name string) error { pw, ok := s.manager.Get(name) if !ok { - return fmt.Errorf("window not found: %s", name) + return coreerr.E("window.taskMinimise", "window not found: "+name, nil) } pw.Minimise() return nil @@ -259,7 +259,7 @@ func (s *Service) taskMinimise(name string) error { func (s *Service) taskFocus(name string) error { pw, ok := s.manager.Get(name) if !ok { - return fmt.Errorf("window not found: %s", name) + return coreerr.E("window.taskFocus", "window not found: "+name, nil) } pw.Focus() return nil @@ -268,7 +268,7 @@ func (s *Service) taskFocus(name string) error { func (s *Service) taskRestore(name string) error { pw, ok := s.manager.Get(name) if !ok { - return fmt.Errorf("window not found: %s", name) + return coreerr.E("window.taskRestore", "window not found: "+name, nil) } pw.Restore() s.manager.State().UpdateMaximized(name, false) @@ -278,7 +278,7 @@ func (s *Service) taskRestore(name string) error { func (s *Service) taskSetTitle(name, title string) error { pw, ok := s.manager.Get(name) if !ok { - return fmt.Errorf("window not found: %s", name) + return coreerr.E("window.taskSetTitle", "window not found: "+name, nil) } pw.SetTitle(title) return nil @@ -287,7 +287,7 @@ func (s *Service) taskSetTitle(name, title string) error { func (s *Service) taskSetVisibility(name string, visible bool) error { pw, ok := s.manager.Get(name) if !ok { - return fmt.Errorf("window not found: %s", name) + return coreerr.E("window.taskSetVisibility", "window not found: "+name, nil) } pw.SetVisibility(visible) return nil @@ -296,7 +296,7 @@ func (s *Service) taskSetVisibility(name string, visible bool) error { func (s *Service) taskFullscreen(name string, fullscreen bool) error { pw, ok := s.manager.Get(name) if !ok { - return fmt.Errorf("window not found: %s", name) + return coreerr.E("window.taskFullscreen", "window not found: "+name, nil) } if fullscreen { pw.Fullscreen() @@ -321,7 +321,7 @@ func (s *Service) taskSaveLayout(name string) error { func (s *Service) taskRestoreLayout(name string) error { layout, ok := s.manager.Layout().GetLayout(name) if !ok { - return fmt.Errorf("layout not found: %s", name) + return coreerr.E("window.taskRestoreLayout", "layout not found: "+name, nil) } for winName, state := range layout.Windows { pw, found := s.manager.Get(winName) @@ -348,7 +348,7 @@ var tileModeMap = map[string]TileMode{ func (s *Service) taskTileWindows(mode string, names []string) error { tm, ok := tileModeMap[mode] if !ok { - return fmt.Errorf("unknown tile mode: %s", mode) + return coreerr.E("window.taskTileWindows", "unknown tile mode: "+mode, nil) } if len(names) == 0 { names = s.manager.List() @@ -368,7 +368,7 @@ var snapPosMap = map[string]SnapPosition{ func (s *Service) taskSnapWindow(name, position string) error { pos, ok := snapPosMap[position] if !ok { - return fmt.Errorf("unknown snap position: %s", position) + return coreerr.E("window.taskSnapWindow", "unknown snap position: "+position, nil) } return s.manager.SnapWindow(name, pos, 1920, 1080) } diff --git a/pkg/window/service_test.go b/pkg/window/service_test.go index 1911044..4df1108 100644 --- a/pkg/window/service_test.go +++ b/pkg/window/service_test.go @@ -174,3 +174,238 @@ func TestFileDrop_Good(t *testing.T) { assert.Equal(t, "upload-zone", dropped.TargetID) mu.Unlock() } + +// --- TaskMinimise --- + +func TestTaskMinimise_Good(t *testing.T) { + svc, c := newTestWindowService(t) + _, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}}) + + _, handled, err := c.PERFORM(TaskMinimise{Name: "test"}) + require.NoError(t, err) + assert.True(t, handled) + + pw, ok := svc.Manager().Get("test") + require.True(t, ok) + mw := pw.(*mockWindow) + assert.True(t, mw.minimised) +} + +func TestTaskMinimise_Bad(t *testing.T) { + _, c := newTestWindowService(t) + _, handled, err := c.PERFORM(TaskMinimise{Name: "nonexistent"}) + assert.True(t, handled) + assert.Error(t, err) +} + +// --- TaskFocus --- + +func TestTaskFocus_Good(t *testing.T) { + svc, c := newTestWindowService(t) + _, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}}) + + _, handled, err := c.PERFORM(TaskFocus{Name: "test"}) + require.NoError(t, err) + assert.True(t, handled) + + pw, ok := svc.Manager().Get("test") + require.True(t, ok) + mw := pw.(*mockWindow) + assert.True(t, mw.focused) +} + +func TestTaskFocus_Bad(t *testing.T) { + _, c := newTestWindowService(t) + _, handled, err := c.PERFORM(TaskFocus{Name: "nonexistent"}) + assert.True(t, handled) + assert.Error(t, err) +} + +// --- TaskRestore --- + +func TestTaskRestore_Good(t *testing.T) { + svc, c := newTestWindowService(t) + _, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}}) + + // First maximise, then restore + _, _, _ = c.PERFORM(TaskMaximise{Name: "test"}) + + _, handled, err := c.PERFORM(TaskRestore{Name: "test"}) + require.NoError(t, err) + assert.True(t, handled) + + pw, ok := svc.Manager().Get("test") + require.True(t, ok) + mw := pw.(*mockWindow) + assert.False(t, mw.maximised) + + // Verify state was updated + state, ok := svc.Manager().State().GetState("test") + assert.True(t, ok) + assert.False(t, state.Maximized) +} + +func TestTaskRestore_Bad(t *testing.T) { + _, c := newTestWindowService(t) + _, handled, err := c.PERFORM(TaskRestore{Name: "nonexistent"}) + assert.True(t, handled) + assert.Error(t, err) +} + +// --- TaskSetTitle --- + +func TestTaskSetTitle_Good(t *testing.T) { + svc, c := newTestWindowService(t) + _, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}}) + + _, handled, err := c.PERFORM(TaskSetTitle{Name: "test", Title: "New Title"}) + require.NoError(t, err) + assert.True(t, handled) + + pw, ok := svc.Manager().Get("test") + require.True(t, ok) + assert.Equal(t, "New Title", pw.Title()) +} + +func TestTaskSetTitle_Bad(t *testing.T) { + _, c := newTestWindowService(t) + _, handled, err := c.PERFORM(TaskSetTitle{Name: "nonexistent", Title: "Nope"}) + assert.True(t, handled) + assert.Error(t, err) +} + +// --- TaskSetVisibility --- + +func TestTaskSetVisibility_Good(t *testing.T) { + svc, c := newTestWindowService(t) + _, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}}) + + _, handled, err := c.PERFORM(TaskSetVisibility{Name: "test", Visible: true}) + require.NoError(t, err) + assert.True(t, handled) + + pw, ok := svc.Manager().Get("test") + require.True(t, ok) + mw := pw.(*mockWindow) + assert.True(t, mw.visible) + + // Now hide it + _, handled, err = c.PERFORM(TaskSetVisibility{Name: "test", Visible: false}) + require.NoError(t, err) + assert.True(t, handled) + assert.False(t, mw.visible) +} + +func TestTaskSetVisibility_Bad(t *testing.T) { + _, c := newTestWindowService(t) + _, handled, err := c.PERFORM(TaskSetVisibility{Name: "nonexistent", Visible: true}) + assert.True(t, handled) + assert.Error(t, err) +} + +// --- TaskFullscreen --- + +func TestTaskFullscreen_Good(t *testing.T) { + svc, c := newTestWindowService(t) + _, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}}) + + // Enter fullscreen + _, handled, err := c.PERFORM(TaskFullscreen{Name: "test", Fullscreen: true}) + require.NoError(t, err) + assert.True(t, handled) + + pw, ok := svc.Manager().Get("test") + require.True(t, ok) + mw := pw.(*mockWindow) + assert.True(t, mw.fullscreened) + + // Exit fullscreen + _, handled, err = c.PERFORM(TaskFullscreen{Name: "test", Fullscreen: false}) + require.NoError(t, err) + assert.True(t, handled) + assert.False(t, mw.fullscreened) +} + +func TestTaskFullscreen_Bad(t *testing.T) { + _, c := newTestWindowService(t) + _, handled, err := c.PERFORM(TaskFullscreen{Name: "nonexistent", Fullscreen: true}) + assert.True(t, handled) + assert.Error(t, err) +} + +// --- TaskSaveLayout --- + +func TestTaskSaveLayout_Good(t *testing.T) { + svc, c := newTestWindowService(t) + _, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("editor"), WithSize(960, 1080), WithPosition(0, 0)}}) + _, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("terminal"), WithSize(960, 1080), WithPosition(960, 0)}}) + + _, handled, err := c.PERFORM(TaskSaveLayout{Name: "coding"}) + require.NoError(t, err) + assert.True(t, handled) + + // Verify layout was saved with correct window states + layout, ok := svc.Manager().Layout().GetLayout("coding") + assert.True(t, ok) + assert.Equal(t, "coding", layout.Name) + assert.Len(t, layout.Windows, 2) + + editorState, ok := layout.Windows["editor"] + assert.True(t, ok) + assert.Equal(t, 0, editorState.X) + assert.Equal(t, 960, editorState.Width) + + termState, ok := layout.Windows["terminal"] + assert.True(t, ok) + assert.Equal(t, 960, termState.X) + assert.Equal(t, 960, termState.Width) +} + +func TestTaskSaveLayout_Bad(t *testing.T) { + _, c := newTestWindowService(t) + // Saving an empty layout with empty name returns an error from LayoutManager + _, handled, err := c.PERFORM(TaskSaveLayout{Name: ""}) + assert.True(t, handled) + assert.Error(t, err) +} + +// --- TaskRestoreLayout --- + +func TestTaskRestoreLayout_Good(t *testing.T) { + svc, c := newTestWindowService(t) + // Open windows + _, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("editor"), WithSize(800, 600), WithPosition(0, 0)}}) + _, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("terminal"), WithSize(800, 600), WithPosition(0, 0)}}) + + // Save a layout with specific positions + _, _, _ = c.PERFORM(TaskSaveLayout{Name: "coding"}) + + // Move the windows to different positions + _, _, _ = c.PERFORM(TaskSetPosition{Name: "editor", X: 500, Y: 500}) + _, _, _ = c.PERFORM(TaskSetPosition{Name: "terminal", X: 600, Y: 600}) + + // Restore the layout + _, handled, err := c.PERFORM(TaskRestoreLayout{Name: "coding"}) + require.NoError(t, err) + assert.True(t, handled) + + // Verify windows were moved back to saved positions + pw, ok := svc.Manager().Get("editor") + require.True(t, ok) + x, y := pw.Position() + assert.Equal(t, 0, x) + assert.Equal(t, 0, y) + + pw2, ok := svc.Manager().Get("terminal") + require.True(t, ok) + x2, y2 := pw2.Position() + assert.Equal(t, 0, x2) + assert.Equal(t, 0, y2) +} + +func TestTaskRestoreLayout_Bad(t *testing.T) { + _, c := newTestWindowService(t) + _, handled, err := c.PERFORM(TaskRestoreLayout{Name: "nonexistent"}) + assert.True(t, handled) + assert.Error(t, err) +} diff --git a/pkg/window/state.go b/pkg/window/state.go index 3523cfe..2ff9d87 100644 --- a/pkg/window/state.go +++ b/pkg/window/state.go @@ -7,6 +7,8 @@ import ( "path/filepath" "sync" "time" + + coreio "forge.lthn.ai/core/go-io" ) // WindowState holds the persisted position/size of a window. @@ -62,13 +64,13 @@ func (sm *StateManager) load() { if sm.configDir == "" { return } - data, err := os.ReadFile(sm.filePath()) + content, err := coreio.Local.Read(sm.filePath()) if err != nil { return } sm.mu.Lock() defer sm.mu.Unlock() - _ = json.Unmarshal(data, &sm.states) + _ = json.Unmarshal([]byte(content), &sm.states) } func (sm *StateManager) save() { @@ -81,8 +83,8 @@ func (sm *StateManager) save() { if err != nil { return } - _ = os.MkdirAll(sm.configDir, 0o755) - _ = os.WriteFile(sm.filePath(), data, 0o644) + _ = coreio.Local.EnsureDir(sm.configDir) + _ = coreio.Local.Write(sm.filePath(), string(data)) } func (sm *StateManager) scheduleSave() { diff --git a/pkg/window/tiling.go b/pkg/window/tiling.go index 40669fe..e6ee430 100644 --- a/pkg/window/tiling.go +++ b/pkg/window/tiling.go @@ -1,7 +1,7 @@ // pkg/window/tiling.go package window -import "fmt" +import coreerr "forge.lthn.ai/core/go-log" // TileMode defines how windows are arranged. type TileMode int @@ -67,12 +67,12 @@ func (m *Manager) TileWindows(mode TileMode, names []string, screenW, screenH in for _, name := range names { pw, ok := m.Get(name) if !ok { - return fmt.Errorf("window %q not found", name) + return coreerr.E("window.Manager.TileWindows", "window not found: "+name, nil) } windows = append(windows, pw) } if len(windows) == 0 { - return fmt.Errorf("no windows to tile") + return coreerr.E("window.Manager.TileWindows", "no windows to tile", nil) } halfW, halfH := screenW/2, screenH/2 @@ -146,7 +146,7 @@ func (m *Manager) TileWindows(mode TileMode, names []string, screenW, screenH in func (m *Manager) SnapWindow(name string, pos SnapPosition, screenW, screenH int) error { pw, ok := m.Get(name) if !ok { - return fmt.Errorf("window %q not found", name) + return coreerr.E("window.Manager.SnapWindow", "window not found: "+name, nil) } halfW, halfH := screenW/2, screenH/2 @@ -188,7 +188,7 @@ func (m *Manager) StackWindows(names []string, offsetX, offsetY int) error { for i, name := range names { pw, ok := m.Get(name) if !ok { - return fmt.Errorf("window %q not found", name) + return coreerr.E("window.Manager.StackWindows", "window not found: "+name, nil) } pw.SetPosition(i*offsetX, i*offsetY) } @@ -198,7 +198,7 @@ func (m *Manager) StackWindows(names []string, offsetX, offsetY int) error { // ApplyWorkflow arranges windows in a predefined workflow layout. func (m *Manager) ApplyWorkflow(workflow WorkflowLayout, names []string, screenW, screenH int) error { if len(names) == 0 { - return fmt.Errorf("no windows for workflow") + return coreerr.E("window.Manager.ApplyWorkflow", "no windows for workflow", nil) } switch workflow { diff --git a/pkg/window/window.go b/pkg/window/window.go index 3692fe8..937f1fd 100644 --- a/pkg/window/window.go +++ b/pkg/window/window.go @@ -2,8 +2,9 @@ package window import ( - "fmt" "sync" + + coreerr "forge.lthn.ai/core/go-log" ) // Window is CoreGUI's own window descriptor — NOT a Wails type alias. @@ -70,7 +71,7 @@ func NewManagerWithDir(platform Platform, configDir string) *Manager { func (m *Manager) Open(opts ...WindowOption) (PlatformWindow, error) { w, err := ApplyOptions(opts...) if err != nil { - return nil, fmt.Errorf("window.Manager.Open: %w", err) + return nil, coreerr.E("window.Manager.Open", "failed to apply options", err) } return m.Create(w) } diff --git a/pkg/window/window_test.go b/pkg/window/window_test.go index 44d1f09..4ed75e5 100644 --- a/pkg/window/window_test.go +++ b/pkg/window/window_test.go @@ -148,131 +148,6 @@ func TestManager_Remove_Good(t *testing.T) { assert.False(t, ok) } -// --- StateManager Tests --- - -// newTestStateManager creates a clean StateManager with a temp dir for testing. -func newTestStateManager(t *testing.T) *StateManager { - return &StateManager{ - configDir: t.TempDir(), - states: make(map[string]WindowState), - } -} - -func TestStateManager_SetGet_Good(t *testing.T) { - sm := newTestStateManager(t) - state := WindowState{X: 100, Y: 200, Width: 800, Height: 600, Maximized: false} - sm.SetState("main", state) - got, ok := sm.GetState("main") - assert.True(t, ok) - assert.Equal(t, 100, got.X) - assert.Equal(t, 800, got.Width) -} - -func TestStateManager_SetGet_Bad(t *testing.T) { - sm := newTestStateManager(t) - _, ok := sm.GetState("nonexistent") - assert.False(t, ok) -} - -func TestStateManager_CaptureState_Good(t *testing.T) { - sm := newTestStateManager(t) - w := &mockWindow{name: "cap", x: 50, y: 60, width: 1024, height: 768, maximised: true} - sm.CaptureState(w) - got, ok := sm.GetState("cap") - assert.True(t, ok) - assert.Equal(t, 50, got.X) - assert.Equal(t, 1024, got.Width) - assert.True(t, got.Maximized) -} - -func TestStateManager_ApplyState_Good(t *testing.T) { - sm := newTestStateManager(t) - sm.SetState("win", WindowState{X: 10, Y: 20, Width: 640, Height: 480}) - w := &Window{Name: "win", Width: 1280, Height: 800} - sm.ApplyState(w) - assert.Equal(t, 10, w.X) - assert.Equal(t, 20, w.Y) - assert.Equal(t, 640, w.Width) - assert.Equal(t, 480, w.Height) -} - -func TestStateManager_ListStates_Good(t *testing.T) { - sm := newTestStateManager(t) - sm.SetState("a", WindowState{Width: 100}) - sm.SetState("b", WindowState{Width: 200}) - names := sm.ListStates() - assert.Len(t, names, 2) -} - -func TestStateManager_Clear_Good(t *testing.T) { - sm := newTestStateManager(t) - sm.SetState("a", WindowState{Width: 100}) - sm.Clear() - names := sm.ListStates() - assert.Empty(t, names) -} - -func TestStateManager_Persistence_Good(t *testing.T) { - dir := t.TempDir() - sm1 := &StateManager{configDir: dir, states: make(map[string]WindowState)} - sm1.SetState("persist", WindowState{X: 42, Y: 84, Width: 500, Height: 300}) - sm1.ForceSync() - - sm2 := &StateManager{configDir: dir, states: make(map[string]WindowState)} - sm2.load() - got, ok := sm2.GetState("persist") - assert.True(t, ok) - assert.Equal(t, 42, got.X) - assert.Equal(t, 500, got.Width) -} - -// --- LayoutManager Tests --- - -// newTestLayoutManager creates a clean LayoutManager with a temp dir for testing. -func newTestLayoutManager(t *testing.T) *LayoutManager { - return &LayoutManager{ - configDir: t.TempDir(), - layouts: make(map[string]Layout), - } -} - -func TestLayoutManager_SaveGet_Good(t *testing.T) { - lm := newTestLayoutManager(t) - states := map[string]WindowState{ - "editor": {X: 0, Y: 0, Width: 960, Height: 1080}, - "terminal": {X: 960, Y: 0, Width: 960, Height: 1080}, - } - err := lm.SaveLayout("coding", states) - require.NoError(t, err) - - layout, ok := lm.GetLayout("coding") - assert.True(t, ok) - assert.Equal(t, "coding", layout.Name) - assert.Len(t, layout.Windows, 2) -} - -func TestLayoutManager_GetLayout_Bad(t *testing.T) { - lm := newTestLayoutManager(t) - _, ok := lm.GetLayout("nonexistent") - assert.False(t, ok) -} - -func TestLayoutManager_ListLayouts_Good(t *testing.T) { - lm := newTestLayoutManager(t) - _ = lm.SaveLayout("a", map[string]WindowState{}) - _ = lm.SaveLayout("b", map[string]WindowState{}) - layouts := lm.ListLayouts() - assert.Len(t, layouts, 2) -} - -func TestLayoutManager_DeleteLayout_Good(t *testing.T) { - lm := newTestLayoutManager(t) - _ = lm.SaveLayout("temp", map[string]WindowState{}) - lm.DeleteLayout("temp") - _, ok := lm.GetLayout("temp") - assert.False(t, ok) -} - // --- Tiling Tests --- func TestTileMode_String_Good(t *testing.T) { @@ -328,3 +203,190 @@ func TestWorkflowLayout_Good(t *testing.T) { assert.Equal(t, "coding", WorkflowCoding.String()) assert.Equal(t, "debugging", WorkflowDebugging.String()) } + +// --- Comprehensive Tiling Tests --- + +func TestTileWindows_AllModes_Good(t *testing.T) { + const screenW, screenH = 1920, 1080 + halfW, halfH := screenW/2, screenH/2 + + tests := []struct { + name string + mode TileMode + wantX int + wantY int + wantWidth int + wantHeight int + }{ + {"LeftHalf", TileModeLeftHalf, 0, 0, halfW, screenH}, + {"RightHalf", TileModeRightHalf, halfW, 0, halfW, screenH}, + {"TopHalf", TileModeTopHalf, 0, 0, screenW, halfH}, + {"BottomHalf", TileModeBottomHalf, 0, halfH, screenW, halfH}, + {"TopLeft", TileModeTopLeft, 0, 0, halfW, halfH}, + {"TopRight", TileModeTopRight, halfW, 0, halfW, halfH}, + {"BottomLeft", TileModeBottomLeft, 0, halfH, halfW, halfH}, + {"BottomRight", TileModeBottomRight, halfW, halfH, halfW, halfH}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + m, _ := newTestManager() + _, err := m.Open(WithName("win"), WithSize(800, 600)) + require.NoError(t, err) + + err = m.TileWindows(tc.mode, []string{"win"}, screenW, screenH) + require.NoError(t, err) + + pw, ok := m.Get("win") + require.True(t, ok) + + x, y := pw.Position() + w, h := pw.Size() + assert.Equal(t, tc.wantX, x, "x position") + assert.Equal(t, tc.wantY, y, "y position") + assert.Equal(t, tc.wantWidth, w, "width") + assert.Equal(t, tc.wantHeight, h, "height") + }) + } +} + +func TestSnapWindow_AllPositions_Good(t *testing.T) { + const screenW, screenH = 1920, 1080 + halfW, halfH := screenW/2, screenH/2 + + tests := []struct { + name string + pos SnapPosition + initW int + initH int + wantX int + wantY int + wantWidth int + wantHeight int + }{ + {"Right", SnapRight, 800, 600, halfW, 0, halfW, screenH}, + {"Top", SnapTop, 800, 600, 0, 0, screenW, halfH}, + {"Bottom", SnapBottom, 800, 600, 0, halfH, screenW, halfH}, + {"TopLeft", SnapTopLeft, 800, 600, 0, 0, halfW, halfH}, + {"TopRight", SnapTopRight, 800, 600, halfW, 0, halfW, halfH}, + {"BottomLeft", SnapBottomLeft, 800, 600, 0, halfH, halfW, halfH}, + {"BottomRight", SnapBottomRight, 800, 600, halfW, halfH, halfW, halfH}, + {"Center", SnapCenter, 800, 600, (screenW - 800) / 2, (screenH - 600) / 2, 800, 600}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + m, _ := newTestManager() + _, err := m.Open(WithName("snap"), WithSize(tc.initW, tc.initH)) + require.NoError(t, err) + + err = m.SnapWindow("snap", tc.pos, screenW, screenH) + require.NoError(t, err) + + pw, ok := m.Get("snap") + require.True(t, ok) + + x, y := pw.Position() + w, h := pw.Size() + assert.Equal(t, tc.wantX, x, "x position") + assert.Equal(t, tc.wantY, y, "y position") + assert.Equal(t, tc.wantWidth, w, "width") + assert.Equal(t, tc.wantHeight, h, "height") + }) + } +} + +func TestStackWindows_ThreeWindows_Good(t *testing.T) { + m, _ := newTestManager() + names := []string{"s1", "s2", "s3"} + for _, name := range names { + _, err := m.Open(WithName(name), WithSize(800, 600)) + require.NoError(t, err) + } + + err := m.StackWindows(names, 30, 30) + require.NoError(t, err) + + for i, name := range names { + pw, ok := m.Get(name) + require.True(t, ok, "window %s should exist", name) + x, y := pw.Position() + assert.Equal(t, i*30, x, "window %s x position", name) + assert.Equal(t, i*30, y, "window %s y position", name) + } +} + +func TestApplyWorkflow_AllLayouts_Good(t *testing.T) { + const screenW, screenH = 1920, 1080 + + tests := []struct { + name string + workflow WorkflowLayout + // Expected positions/sizes for the first two windows. + // For WorkflowSideBySide, TileWindows(LeftRight) divides equally. + win0X, win0Y, win0W, win0H int + win1X, win1Y, win1W, win1H int + }{ + { + "Coding", + WorkflowCoding, + 0, 0, 1344, screenH, // 70% of 1920 = 1344 + 1344, 0, screenW - 1344, screenH, // remaining 30% + }, + { + "Debugging", + WorkflowDebugging, + 0, 0, 1152, screenH, // 60% of 1920 = 1152 + 1152, 0, screenW - 1152, screenH, // remaining 40% + }, + { + "Presenting", + WorkflowPresenting, + 0, 0, screenW, screenH, // maximised + 0, 0, 800, 600, // second window untouched + }, + { + "SideBySide", + WorkflowSideBySide, + 0, 0, 960, screenH, // left half (1920/2) + 960, 0, 960, screenH, // right half + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + m, _ := newTestManager() + _, err := m.Open(WithName("editor"), WithSize(800, 600)) + require.NoError(t, err) + _, err = m.Open(WithName("terminal"), WithSize(800, 600)) + require.NoError(t, err) + + err = m.ApplyWorkflow(tc.workflow, []string{"editor", "terminal"}, screenW, screenH) + require.NoError(t, err) + + pw0, ok := m.Get("editor") + require.True(t, ok) + x0, y0 := pw0.Position() + w0, h0 := pw0.Size() + assert.Equal(t, tc.win0X, x0, "editor x") + assert.Equal(t, tc.win0Y, y0, "editor y") + assert.Equal(t, tc.win0W, w0, "editor width") + assert.Equal(t, tc.win0H, h0, "editor height") + + pw1, ok := m.Get("terminal") + require.True(t, ok) + x1, y1 := pw1.Position() + w1, h1 := pw1.Size() + assert.Equal(t, tc.win1X, x1, "terminal x") + assert.Equal(t, tc.win1Y, y1, "terminal y") + assert.Equal(t, tc.win1W, w1, "terminal width") + assert.Equal(t, tc.win1H, h1, "terminal height") + }) + } +} + +func TestApplyWorkflow_Empty_Bad(t *testing.T) { + m, _ := newTestManager() + err := m.ApplyWorkflow(WorkflowCoding, []string{}, 1920, 1080) + assert.Error(t, err) +}