fix(dx): use coreerr.E() and go-io, update CLAUDE.md, add tests
- Replace 90+ fmt.Errorf calls with coreerr.E() from go-log across display, window, systray, keybinding, contextmenu, and mcp packages - Replace os.ReadFile/WriteFile/MkdirAll with coreio.Local in window/layout.go and window/state.go - Update CLAUDE.md: fix key files table for new package structure, document error handling and file I/O conventions, add missing deps - Add 37 tests for window package (task handlers, persistence, tiling modes, snap positions, workflow layouts) - Window coverage: 47.1% → 69.8% Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
29d92601cb
commit
b559562dd9
26 changed files with 908 additions and 275 deletions
55
CLAUDE.md
55
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
|
||||
|
||||
|
|
|
|||
4
go.mod
4
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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
318
pkg/window/persistence_test.go
Normal file
318
pkg/window/persistence_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue